Skip to content

Commit 00f8bfe

Browse files
Ask for an alias on login when the email can't be retrieved
1 parent 0202391 commit 00f8bfe

6 files changed

Lines changed: 153 additions & 24 deletions

File tree

packages/cli-kit/src/private/node/session.test.ts

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@ const validIdentityToken: IdentityToken = {
4040
expiresAt: futureDate,
4141
scopes: ['scope', 'scope2'],
4242
userId,
43-
alias: userId,
4443
}
4544

4645
const validTokens: OAuthSession = {
@@ -180,7 +179,7 @@ The CLI is currently unable to prompt for reauthentication.`,
180179
expect(fetchSessions).toHaveBeenCalledOnce()
181180
})
182181

183-
test('falls back to userId when email fetch fails', async () => {
182+
test('leaves alias undefined when email fetch fails', async () => {
184183
// Given
185184
vi.mocked(validateSession).mockResolvedValueOnce('needs_full_auth')
186185
vi.mocked(fetchSessions).mockResolvedValue(undefined)
@@ -194,9 +193,8 @@ The CLI is currently unable to prompt for reauthentication.`,
194193
expect(businessPlatformRequest).toHaveBeenCalled()
195194
expect(storeSessions).toHaveBeenCalledOnce()
196195

197-
// Verify the session was stored with userId as alias (fallback)
198196
const storedSession = vi.mocked(storeSessions).mock.calls[0]![0]
199-
expect(storedSession[fqdn]![userId]!.identity.alias).toBe(userId)
197+
expect(storedSession[fqdn]![userId]!.identity.alias).toBeUndefined()
200198

201199
expect(got).toEqual(validTokens)
202200
})
@@ -207,7 +205,7 @@ The CLI is currently unable to prompt for reauthentication.`,
207205
vi.mocked(fetchSessions).mockResolvedValue(undefined)
208206

209207
// When
210-
const got = await ensureAuthenticated(defaultApplications)
208+
await ensureAuthenticated(defaultApplications)
211209

212210
// Then
213211
expect(businessPlatformRequest).toHaveBeenCalledWith(expect.any(String), 'access_token')
@@ -666,7 +664,32 @@ describe('ensureAuthenticated email fetch functionality', () => {
666664
expect(got).toEqual(validTokens)
667665
})
668666

669-
test('uses userId as alias when email is not available', async () => {
667+
test('preserves existing alias during token refresh error fallback when email fetch fails', async () => {
668+
// Given
669+
const sessionsWithAlias: Sessions = {
670+
[fqdn]: {
671+
[userId]: {
672+
identity: {...validIdentityToken, alias: 'my-custom-alias'},
673+
applications: {},
674+
},
675+
},
676+
}
677+
const tokenResponseError = new InvalidGrantError()
678+
vi.mocked(validateSession).mockResolvedValueOnce('needs_refresh')
679+
vi.mocked(fetchSessions).mockResolvedValue(sessionsWithAlias)
680+
vi.mocked(refreshAccessToken).mockRejectedValueOnce(tokenResponseError)
681+
vi.mocked(businessPlatformRequest).mockRejectedValueOnce(new Error('API Error'))
682+
683+
// When
684+
const got = await ensureAuthenticated(defaultApplications)
685+
686+
// Then
687+
const storedSession = vi.mocked(storeSessions).mock.calls[0]![0]
688+
expect(storedSession[fqdn]![userId]!.identity.alias).toBe('my-custom-alias')
689+
expect(got).toEqual(validTokens)
690+
})
691+
692+
test('leaves alias undefined when email is not available', async () => {
670693
// Given
671694
vi.mocked(validateSession).mockResolvedValueOnce('needs_full_auth')
672695
vi.mocked(fetchSessions).mockResolvedValue(undefined)
@@ -681,7 +704,7 @@ describe('ensureAuthenticated email fetch functionality', () => {
681704

682705
// Then
683706
const storedSession = vi.mocked(storeSessions).mock.calls[0]![0]
684-
expect(storedSession[fqdn]![userId]!.identity.alias).toBe(userId)
707+
expect(storedSession[fqdn]![userId]!.identity.alias).toBeUndefined()
685708
expect(got).toEqual(validTokens)
686709
})
687710
})

packages/cli-kit/src/private/node/session.ts

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ async function fetchEmail(businessPlatformToken: string | undefined): Promise<st
3232

3333
try {
3434
const userEmailResult = await businessPlatformRequest<UserEmailQuery>(UserEmailQueryString, businessPlatformToken)
35-
return userEmailResult.currentUserAccount?.email
35+
return userEmailResult.currentUserAccount?.email ?? undefined
3636
// eslint-disable-next-line no-catch-all/no-catch-all
3737
} catch (error) {
3838
outputDebug(outputContent`Failed to fetch user email: ${(error as Error).message ?? String(error)}`)
@@ -227,15 +227,15 @@ ${outputToken.json(applications)}
227227
if (validationResult === 'needs_full_auth') {
228228
await throwOnNoPrompt(noPrompt)
229229
outputDebug(outputContent`Initiating the full authentication flow...`)
230-
newSession = await executeCompleteFlow(applications)
230+
newSession = await executeCompleteFlow(applications, currentSession?.identity.alias)
231231
} else if (validationResult === 'needs_refresh' || forceRefresh) {
232232
outputDebug(outputContent`The current session is valid but needs refresh. Refreshing...`)
233233
try {
234234
newSession = await refreshTokens(currentSession!, applications)
235235
} catch (error) {
236236
if (error instanceof InvalidGrantError) {
237237
await throwOnNoPrompt(noPrompt)
238-
newSession = await executeCompleteFlow(applications)
238+
newSession = await executeCompleteFlow(applications, currentSession?.identity.alias)
239239
} else if (error instanceof InvalidRequestError) {
240240
await sessionStore.remove()
241241
throw new AbortError('\nError validating auth session', "We've cleared the current session, please try again")
@@ -286,9 +286,9 @@ The CLI is currently unable to prompt for reauthentication.`,
286286
* Execute the full authentication flow.
287287
*
288288
* @param applications - An object containing the applications we need to be authenticated with.
289-
* @param alias - Optional alias to use for the session.
289+
* @param existingAlias - Optional alias from a previous session to preserve if the email fetch fails.
290290
*/
291-
async function executeCompleteFlow(applications: OAuthApplications): Promise<Session> {
291+
async function executeCompleteFlow(applications: OAuthApplications, existingAlias?: string): Promise<Session> {
292292
const scopes = getFlattenScopes(applications)
293293
if (firstPartyDev()) {
294294
outputDebug(outputContent`Authenticating as Shopify Employee...`)
@@ -311,9 +311,8 @@ async function executeCompleteFlow(applications: OAuthApplications): Promise<Ses
311311

312312
outputDebug(outputContent`CLI token received (PCAT). Using it directly for API access...`)
313313

314-
// Get the alias for the session (email or userId)
315-
// Use the PCAT identity token directly to fetch the email
316-
const alias = (await fetchEmail(identityToken.accessToken)) ?? identityToken.userId
314+
// Preserve existing alias if available, otherwise try fetching email
315+
const alias = existingAlias ?? (await fetchEmail(identityToken.accessToken))
317316

318317
const session: Session = {
319318
identity: {
@@ -388,6 +387,6 @@ function buildIdentityTokenFromEnv(
388387
...identityTokenInformation,
389388
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
390389
scopes,
391-
alias: identityTokenInformation.userId,
390+
alias: undefined,
392391
}
393392
}

packages/cli-kit/src/private/node/session/store.test.ts

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {Sessions} from './schema.js'
2-
import {store, fetch, remove, getSessionAlias, findSessionByAlias} from './store.js'
2+
import {store, fetch, remove, getSessionAlias, setSessionAlias, findSessionByAlias} from './store.js'
33
import {getSessions, removeSessions, setSessions, removeCurrentSessionId} from '../conf-store.js'
44
import {identityFqdn} from '../../../public/node/context/fqdn.js'
55

@@ -28,7 +28,6 @@ const mockSessions: Sessions = {
2828
expiresAt: new Date(),
2929
scopes: ['scope2'],
3030
userId: 'user2',
31-
alias: 'user2',
3231
},
3332
applications: {},
3433
},
@@ -132,6 +131,54 @@ describe('session store', () => {
132131
})
133132
})
134133

134+
describe('setSessionAlias', () => {
135+
test('updates alias for an existing user', async () => {
136+
// Given
137+
vi.mocked(getSessions).mockReturnValue(JSON.stringify(mockSessions))
138+
139+
// When
140+
await setSessionAlias('user1', 'New Alias')
141+
142+
// Then
143+
const storedSessions = JSON.parse(vi.mocked(setSessions).mock.calls[0]![0])
144+
expect(storedSessions['identity.fqdn.com'].user1.identity.alias).toBe('New Alias')
145+
})
146+
147+
test('does nothing when no sessions exist', async () => {
148+
// Given
149+
vi.mocked(getSessions).mockReturnValue(undefined)
150+
151+
// When
152+
await setSessionAlias('user1', 'New Alias')
153+
154+
// Then
155+
expect(setSessions).not.toHaveBeenCalled()
156+
})
157+
158+
test('does nothing when user does not exist', async () => {
159+
// Given
160+
vi.mocked(getSessions).mockReturnValue(JSON.stringify(mockSessions))
161+
162+
// When
163+
await setSessionAlias('nonexistent', 'New Alias')
164+
165+
// Then
166+
expect(setSessions).not.toHaveBeenCalled()
167+
})
168+
169+
test('does nothing when fqdn does not match', async () => {
170+
// Given
171+
vi.mocked(getSessions).mockReturnValue(JSON.stringify(mockSessions))
172+
vi.mocked(identityFqdn).mockResolvedValue('different.fqdn.com')
173+
174+
// When
175+
await setSessionAlias('user1', 'New Alias')
176+
177+
// Then
178+
expect(setSessions).not.toHaveBeenCalled()
179+
})
180+
})
181+
135182
describe('findSessionByAlias', () => {
136183
test('returns userId for existing alias', async () => {
137184
// Given

packages/cli-kit/src/private/node/session/store.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import {SessionsSchema} from './schema.js'
22
import {getSessions, removeCurrentSessionId, removeSessions, setSessions} from '../conf-store.js'
33
import {identityFqdn} from '../../../public/node/context/fqdn.js'
4-
import type {Sessions} from './schema.js'
4+
import type {Session, Sessions} from './schema.js'
55

66
/**
77
* Serializes the session as a JSON and stores it in the system.
@@ -56,6 +56,24 @@ export async function getSessionAlias(userId: string): Promise<string | undefine
5656
return sessions[fqdn][userId].identity.alias
5757
}
5858

59+
/**
60+
* Sets the alias for a given user's session and persists it.
61+
*
62+
* @param userId - The user ID of the session to update.
63+
* @param alias - The new alias to set.
64+
*/
65+
export async function setSessionAlias(userId: string, alias: string): Promise<void> {
66+
const sessions = await fetch()
67+
if (!sessions) return
68+
69+
const fqdn = await identityFqdn()
70+
const session: Session | undefined = sessions[fqdn]?.[userId]
71+
if (!session) return
72+
73+
session.identity.alias = alias
74+
await store(sessions)
75+
}
76+
5977
/**
6078
* Finds a session by its alias.
6179
*

packages/cli-kit/src/public/node/session-prompt.test.ts

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {promptSessionSelect} from './session-prompt.js'
2-
import {renderSelectPrompt} from './ui.js'
2+
import {renderSelectPrompt, renderTextPrompt} from './ui.js'
33
import {ensureAuthenticatedUser} from './session.js'
44
import {identityFqdn} from './context/fqdn.js'
55
import {setCurrentSessionId} from '../../private/node/conf-store.js'
@@ -34,8 +34,6 @@ const mockSessions: Sessions = {
3434
expiresAt: new Date(),
3535
scopes: ['scope2'],
3636
userId: 'user2',
37-
// Default alias is same as userId
38-
alias: 'user2',
3937
},
4038
applications: {},
4139
},
@@ -64,6 +62,21 @@ describe('promptSessionSelect', () => {
6462
expect(result).toEqual('new-alias')
6563
})
6664

65+
test('prompts for alias when no alias is stored', async () => {
66+
// Given
67+
vi.mocked(sessionStore.fetch).mockResolvedValue(undefined)
68+
vi.mocked(sessionStore.getSessionAlias).mockResolvedValue(undefined)
69+
vi.mocked(renderTextPrompt).mockResolvedValue('typed-alias')
70+
71+
// When
72+
const result = await promptSessionSelect()
73+
74+
// Then
75+
expect(renderTextPrompt).toHaveBeenCalled()
76+
expect(sessionStore.setSessionAlias).toHaveBeenCalledWith('new-user-id', 'typed-alias')
77+
expect(result).toEqual('typed-alias')
78+
})
79+
6780
test('prompts user to create new session with alias when no existing sessions', async () => {
6881
// Given
6982
vi.mocked(sessionStore.fetch).mockResolvedValue(undefined)
@@ -166,6 +179,25 @@ describe('promptSessionSelect', () => {
166179
expect(result).toEqual('custom-alias')
167180
})
168181

182+
test('prompts for alias when selecting "Log in with a different account" and no alias is stored', async () => {
183+
// Given
184+
vi.mocked(sessionStore.fetch).mockResolvedValue(mockSessions)
185+
vi.mocked(renderSelectPrompt).mockResolvedValue('NEW_LOGIN')
186+
vi.mocked(sessionStore.getSessionAlias).mockResolvedValue(undefined)
187+
vi.mocked(renderTextPrompt).mockResolvedValue('my-work-account')
188+
189+
// When
190+
const result = await promptSessionSelect()
191+
192+
// Then
193+
expect(ensureAuthenticatedUser).toHaveBeenCalledWith({}, {forceNewSession: true})
194+
expect(renderTextPrompt).toHaveBeenCalledWith({
195+
message: 'Enter an alias for this account (e.g. your email or a nickname)',
196+
})
197+
expect(sessionStore.setSessionAlias).toHaveBeenCalledWith('new-user-id', 'my-work-account')
198+
expect(result).toEqual('my-work-account')
199+
})
200+
169201
test('does not update alias for existing session when not provided', async () => {
170202
// Given
171203
vi.mocked(sessionStore.fetch).mockResolvedValue(mockSessions)

packages/cli-kit/src/public/node/session-prompt.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {renderSelectPrompt} from './ui.js'
1+
import {renderSelectPrompt, renderTextPrompt} from './ui.js'
22
import {ensureAuthenticatedUser} from './session.js'
33
import {identityFqdn} from './context/fqdn.js'
44
import * as sessionStore from '../../private/node/session/store.js'
@@ -37,13 +37,23 @@ function buildSessionChoices(sessions: Sessions, fqdn: string): SessionChoice[]
3737

3838
/**
3939
* Handles the new login flow.
40+
* If no alias is stored (email couldn't be fetched), prompts the user for a friendly alias.
4041
*
4142
* @returns The alias of the authenticated user.
4243
*/
4344
async function handleNewLogin(): Promise<string> {
4445
const result = await ensureAuthenticatedUser({}, {forceNewSession: true})
4546
const alias = await sessionStore.getSessionAlias(result.userId)
46-
return alias ?? result.userId
47+
48+
if (!alias) {
49+
const userAlias = await renderTextPrompt({
50+
message: 'Enter an alias for this account (e.g. your email or a nickname)',
51+
})
52+
await sessionStore.setSessionAlias(result.userId, userAlias)
53+
return userAlias
54+
}
55+
56+
return alias
4757
}
4858

4959
/**

0 commit comments

Comments
 (0)