From ee03e023d21d263d7fc43696002590d3d6c29f5e Mon Sep 17 00:00:00 2001 From: Guillaume Jacquart Date: Tue, 11 Aug 2026 17:25:52 +0200 Subject: [PATCH] fix(editor): Show the connected provider account, not the n8n account email (#35553) Co-authored-by: Claude Opus 5 (1M context) --- .../credential-connection-status.ts | 6 ++ .../__tests__/credentials.service.test.ts | 15 ++- ...al-connection-status-provider.interface.ts | 18 +++- .../credential-connection-status-proxy.ts | 14 ++- .../src/credentials/credentials.service.ee.ts | 3 +- .../src/credentials/credentials.service.ts | 34 ++++--- ...edential-connection-status.service.test.ts | 84 ++++++++++++++++- .../credential-connection-status.service.ts | 72 +++++++++++++-- .../instance-ai.adapter.service.ts | 15 ++- .../__tests__/account-identifier.test.ts | 91 +++++++++++++++++++ .../src/oauth/__tests__/oauth.service.test.ts | 46 ---------- packages/cli/src/oauth/account-identifier.ts | 59 ++++++++++++ packages/cli/src/oauth/oauth.service.ts | 46 +--------- .../credentials.resolvable.api.test.ts | 65 +++++++++++++ packages/frontend/editor-ui/src/Interface.ts | 1 + .../CredentialEdit/CredentialConfig.test.ts | 55 +++++++++++ .../CredentialEdit/CredentialConfig.vue | 16 ++-- .../CredentialEdit/CredentialEdit.vue | 14 ++- .../components/NodeCredentials.test.ts | 33 +++++++ .../components/NodeCredentials.vue | 22 ++++- .../composables/useCredentialForm.ts | 8 ++ .../features/credentials/credentials.store.ts | 13 ++- .../features/credentials/credentials.types.ts | 12 +++ 23 files changed, 592 insertions(+), 150 deletions(-) create mode 100644 packages/cli/src/oauth/__tests__/account-identifier.test.ts create mode 100644 packages/cli/src/oauth/account-identifier.ts diff --git a/packages/@n8n/api-types/src/dto/credentials/credential-connection-status.ts b/packages/@n8n/api-types/src/dto/credentials/credential-connection-status.ts index 1e8affcbe89..60dd72bc5d8 100644 --- a/packages/@n8n/api-types/src/dto/credentials/credential-connection-status.ts +++ b/packages/@n8n/api-types/src/dto/credentials/credential-connection-status.ts @@ -5,6 +5,12 @@ */ export interface CredentialConnectionStatus { connectedByMe?: boolean; + /** + * The provider account the requesting user's own connection authenticates as + * (e.g. the connected Gmail address). Undefined when the provider returns no + * identity claim, which is common — never fall back to the n8n account here. + */ + connectedAccountIdentifier?: string; /** Total number of users who have a per-user entry for this credential. */ connectedUserCount?: number; } diff --git a/packages/cli/src/credentials/__tests__/credentials.service.test.ts b/packages/cli/src/credentials/__tests__/credentials.service.test.ts index 95bb7f60ee6..dd9f7d5e386 100644 --- a/packages/cli/src/credentials/__tests__/credentials.service.test.ts +++ b/packages/cli/src/credentials/__tests__/credentials.service.test.ts @@ -1249,16 +1249,21 @@ describe('CredentialsService', () => { transactionManager.findOneBy.mockResolvedValue(credential); setRepositoryTransaction(transactionManager); const encrypted = mock({ id: credential.id }); - connectionStatusProxy.findConnectedCredentialIds.mockResolvedValue(new Set([credential.id])); + connectionStatusProxy.findMyConnections.mockResolvedValue( + new Map([[credential.id, { accountIdentifier: 'me@gmail.com' }]]), + ); const result = await service.update(credential.id, encrypted, undefined, { user: ownerUser, }); - expect(connectionStatusProxy.findConnectedCredentialIds).toHaveBeenCalledWith(ownerUser.id, [ + expect(connectionStatusProxy.findMyConnections).toHaveBeenCalledWith(ownerUser.id, [ credential.id, ]); - expect(result).toMatchObject({ connectedByMe: true }); + expect(result).toMatchObject({ + connectedByMe: true, + connectedAccountIdentifier: 'me@gmail.com', + }); }); it('does not enrich the returned credential when no user is passed', async () => { @@ -1273,7 +1278,7 @@ describe('CredentialsService', () => { await service.update(credential.id, encrypted); - expect(connectionStatusProxy.findConnectedCredentialIds).not.toHaveBeenCalled(); + expect(connectionStatusProxy.findMyConnections).not.toHaveBeenCalled(); }); }); @@ -2470,7 +2475,7 @@ describe('CredentialsService', () => { (c) => ({ ...c, scopes: ['credential:read'] }) as ListQueryDb.Credentials.WithScopes, ); sharedCredentialsRepository.getAllRelationsForCredentials.mockResolvedValue([]); - connectionStatusProxy.findConnectedCredentialIds.mockResolvedValue(new Set()); + connectionStatusProxy.findMyConnections.mockResolvedValue(new Map()); ownershipService.addOwnedByAndSharedWith.mockImplementation( (c: ListQueryDb.Credentials.WithOwnedByAndSharedWith) => { c.homeProject = null; diff --git a/packages/cli/src/credentials/credential-connection-status-provider.interface.ts b/packages/cli/src/credentials/credential-connection-status-provider.interface.ts index 554dfe35c8d..0c39fa8fe4a 100644 --- a/packages/cli/src/credentials/credential-connection-status-provider.interface.ts +++ b/packages/cli/src/credentials/credential-connection-status-provider.interface.ts @@ -1,5 +1,15 @@ import type { EntityManager } from '@n8n/typeorm'; +/** One user's connection to one credential. */ +export type UserConnection = { + /** + * The provider account the connection authenticates as (e.g. the connected + * Gmail address). Undefined whenever the provider returns no identity claim, + * which is common — callers must be able to render a connection without it. + */ + accountIdentifier?: string; +}; + /** * Interface for per-user credential connection state providers. * @@ -10,17 +20,17 @@ import type { EntityManager } from '@n8n/typeorm'; * * Modules register a concrete provider at init time; if no provider is * registered, the {@link CredentialConnectionStatusProxy} degrades to a no-op - * (empty set), so read endpoints stay functional even when the + * (empty map), so read endpoints stay functional even when the * dynamic-credentials feature is disabled. */ export interface ICredentialConnectionStatusProvider { /** - * Returns the subset of `credentialIds` for which the user has at least - * one per-user storage entry. + * Returns the subset of `credentialIds` the user has a per-user storage entry + * for, keyed by credential id. * * Implementations must execute a single bulk query (no N+1). */ - findConnectedCredentialIds(userId: string, credentialIds: string[]): Promise>; + findMyConnections(userId: string, credentialIds: string[]): Promise>; /** * Returns the number of distinct users who have a per-user entry for this diff --git a/packages/cli/src/credentials/credential-connection-status-proxy.ts b/packages/cli/src/credentials/credential-connection-status-proxy.ts index 727627f0253..a08c1c561d6 100644 --- a/packages/cli/src/credentials/credential-connection-status-proxy.ts +++ b/packages/cli/src/credentials/credential-connection-status-proxy.ts @@ -1,7 +1,10 @@ import { Service } from '@n8n/di'; import type { EntityManager } from '@n8n/typeorm'; -import type { ICredentialConnectionStatusProvider } from './credential-connection-status-provider.interface'; +import type { + ICredentialConnectionStatusProvider, + UserConnection, +} from './credential-connection-status-provider.interface'; /** * Proxy between the core credentials service and module-owned per-user @@ -17,9 +20,12 @@ export class CredentialConnectionStatusProxy implements ICredentialConnectionSta this.provider = provider; } - async findConnectedCredentialIds(userId: string, credentialIds: string[]): Promise> { - if (!this.provider || credentialIds.length === 0) return new Set(); - return await this.provider.findConnectedCredentialIds(userId, credentialIds); + async findMyConnections( + userId: string, + credentialIds: string[], + ): Promise> { + if (!this.provider || credentialIds.length === 0) return new Map(); + return await this.provider.findMyConnections(userId, credentialIds); } async countConnectedUsers(credentialId: string): Promise { diff --git a/packages/cli/src/credentials/credentials.service.ee.ts b/packages/cli/src/credentials/credentials.service.ee.ts index 846c7817a5f..bd285476dfe 100644 --- a/packages/cli/src/credentials/credentials.service.ee.ts +++ b/packages/cli/src/credentials/credentials.service.ee.ts @@ -1,3 +1,4 @@ +import type { CredentialConnectionStatus } from '@n8n/api-types'; import { LicenseState } from '@n8n/backend-common'; import type { User } from '@n8n/db'; import { @@ -157,7 +158,7 @@ export class EnterpriseCredentialsService { const { data: _, ...rest } = credential; - const enriched: typeof rest & { connectedByMe?: boolean; connectedUserCount?: number } = rest; + const enriched: typeof rest & CredentialConnectionStatus = rest; await this.credentialsService.populateConnectedByMe([enriched], user); if (credential.isResolvable) { diff --git a/packages/cli/src/credentials/credentials.service.ts b/packages/cli/src/credentials/credentials.service.ts index a429a9eed0c..94745c86f3e 100644 --- a/packages/cli/src/credentials/credentials.service.ts +++ b/packages/cli/src/credentials/credentials.service.ts @@ -1,4 +1,4 @@ -import type { CreateCredentialDto } from '@n8n/api-types'; +import type { CreateCredentialDto, CredentialConnectionStatus } from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; import { Project, @@ -155,8 +155,7 @@ type WorkflowCredentialResult = { homeProject: SlimProject | null; sharedWithProjects: SlimProject[]; currentUserHasAccess: boolean; - connectedByMe?: boolean; -}; +} & CredentialConnectionStatus; /** Codes an auth probe must not treat as rejection, stored as a JSON array in the credential. */ function parseAcceptedStatusCodes(raw: unknown): number[] | undefined { @@ -193,18 +192,18 @@ export class CredentialsService { private readonly dbLockService: DbLockService, ) {} - /** - * Sets `connectedByMe` on every resolvable credential in `credentials`, - * using a single bulk lookup against the per-user storage. Static - * (non-resolvable) credentials are left untouched. - * - * Mutates in place; callers may pass entities, decrypted DTOs, or plain - * object literals — any shape that carries `id` and `isResolvable`. - */ async countConnectedUsers(credentialId: string): Promise { return await this.connectionStatusProxy.countConnectedUsers(credentialId); } + /** + * Sets `connectedByMe` and `connectedAccountIdentifier` on every resolvable + * credential in `credentials`, using a single bulk lookup against the per-user + * storage. Static (non-resolvable) credentials are left untouched. + * + * Mutates in place; callers may pass entities, decrypted DTOs, or plain + * object literals — any shape that carries `id` and `isResolvable`. + */ async populateConnectedByMe( credentials: T[], user: User, @@ -212,13 +211,18 @@ export class CredentialsService { const resolvable = credentials.filter((c) => c.isResolvable === true); if (resolvable.length === 0) return; - const connected = await this.connectionStatusProxy.findConnectedCredentialIds( + const connections = await this.connectionStatusProxy.findMyConnections( user.id, resolvable.map((c) => c.id), ); for (const c of resolvable) { - (c as T & { connectedByMe?: boolean }).connectedByMe = connected.has(c.id); + const connection = connections.get(c.id); + const enriched = c as T & CredentialConnectionStatus; + enriched.connectedByMe = connection !== undefined; + // The account the caller's own connection authenticates as. Only ever + // their own — a connection is never labelled with someone else's account. + enriched.connectedAccountIdentifier = connection?.accountIdentifier; } } @@ -996,7 +1000,7 @@ export class CredentialsService { } // Reflect connections cleared above by deleteUserEntries, not a stale pre-update value. - const enriched: (CredentialsEntity & { connectedByMe?: boolean }) | null = result; + const enriched: (CredentialsEntity & CredentialConnectionStatus) | null = result; if (enriched && options?.user) { await this.populateConnectedByMe([enriched], options.user); } @@ -1564,7 +1568,7 @@ export class CredentialsService { const { data: _, ...rest } = credential; - const enriched: typeof rest & { connectedByMe?: boolean; connectedUserCount?: number } = rest; + const enriched: typeof rest & CredentialConnectionStatus = rest; await this.populateConnectedByMe([enriched], user); if (credential.isResolvable) { diff --git a/packages/cli/src/modules/dynamic-credentials.ee/services/__tests__/credential-connection-status.service.test.ts b/packages/cli/src/modules/dynamic-credentials.ee/services/__tests__/credential-connection-status.service.test.ts index cfeb5828975..0ca0b7e8f3a 100644 --- a/packages/cli/src/modules/dynamic-credentials.ee/services/__tests__/credential-connection-status.service.test.ts +++ b/packages/cli/src/modules/dynamic-credentials.ee/services/__tests__/credential-connection-status.service.test.ts @@ -1,3 +1,4 @@ +import type { Logger } from '@n8n/backend-common'; import type { ProjectRelationRepository, SharedCredentialsRepository, @@ -5,10 +6,12 @@ import type { UserRepository, } from '@n8n/db'; import type { EntityManager } from '@n8n/typeorm'; +import type { Cipher } from 'n8n-core'; import { mock } from 'vitest-mock-extended'; import type { RoleService } from '@/services/role.service'; +import { SYSTEM_RESOLVER_ID } from '../../constants'; import { DynamicCredentialUserEntry } from '../../database/entities/dynamic-credential-user-entry'; import type { DynamicCredentialUserEntryRepository } from '../../database/repositories/dynamic-credential-user-entry.repository'; import { CredentialConnectionStatusService } from '../credential-connection-status.service'; @@ -17,10 +20,11 @@ import { CredentialConnectionStatusService } from '../credential-connection-stat const makeUser = (id: string, globalScopes: string[] = []): User => ({ id, role: { scopes: globalScopes.map((slug) => ({ slug })) } }) as unknown as User; -const makeEntry = (credentialId: string, userId: string): DynamicCredentialUserEntry => { +const makeEntry = (credentialId: string, userId: string, data = ''): DynamicCredentialUserEntry => { const e = new DynamicCredentialUserEntry(); e.credentialId = credentialId; e.userId = userId; + e.data = data; return e; }; @@ -30,6 +34,8 @@ describe('CredentialConnectionStatusService', () => { const sharedCredentialsRepository = mock(); const roleService = mock(); const projectRelationRepository = mock(); + const cipher = mock(); + const logger = mock(); const em = mock(); const service = new CredentialConnectionStatusService( @@ -38,6 +44,8 @@ describe('CredentialConnectionStatusService', () => { sharedCredentialsRepository, roleService, projectRelationRepository, + cipher, + logger, ); const CRED_ID = 'cred-1'; @@ -48,6 +56,80 @@ describe('CredentialConnectionStatusService', () => { roleService.rolesWithScope.mockResolvedValue(VALID_CRED_ROLES); }); + // ──────────────────────────── findMyConnections ─────────────────────────── + + describe('findMyConnections', () => { + const storedToken = (tokenData: Record) => + JSON.stringify({ oauthTokenData: tokenData }); + + it('returns an empty map without querying when no credential ids are given', async () => { + const result = await service.findMyConnections('user-1', []); + + expect(result.size).toBe(0); + expect(repository.find).not.toHaveBeenCalled(); + }); + + it('reports the provider account the stored token belongs to', async () => { + repository.find.mockResolvedValueOnce([makeEntry(CRED_ID, 'user-1', 'encrypted')]); + cipher.decryptV2.mockResolvedValueOnce(storedToken({ email: 'me@gmail.com' })); + + const result = await service.findMyConnections('user-1', [CRED_ID]); + + expect(result.get(CRED_ID)).toEqual({ accountIdentifier: 'me@gmail.com' }); + }); + + it('scopes the lookup to the caller and the system resolver', async () => { + repository.find.mockResolvedValueOnce([]); + + await service.findMyConnections('user-1', [CRED_ID]); + + expect(repository.find).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ userId: 'user-1', resolverId: SYSTEM_RESOLVER_ID }), + }), + ); + }); + + it('reports a connection with no account when the token carries no identity', async () => { + // Gmail asks for no identity scope, so Google returns no id_token. Routine, + // so it must stay silent rather than look like a failure. + repository.find.mockResolvedValueOnce([makeEntry(CRED_ID, 'user-1', 'encrypted')]); + cipher.decryptV2.mockResolvedValueOnce(storedToken({ access_token: 'tok' })); + + const result = await service.findMyConnections('user-1', [CRED_ID]); + + expect(result.has(CRED_ID)).toBe(true); + expect(result.get(CRED_ID)?.accountIdentifier).toBeUndefined(); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it('warns but still reports a connection when its entry cannot be decrypted', async () => { + // Unreadable stored data breaks the credential at execution too, so it is + // not the same class of event as a token without an identity claim. + repository.find.mockResolvedValueOnce([makeEntry(CRED_ID, 'user-1', 'unreadable')]); + cipher.decryptV2.mockRejectedValueOnce(new Error('bad decrypt')); + + const result = await service.findMyConnections('user-1', [CRED_ID]); + + expect(result.has(CRED_ID)).toBe(true); + expect(result.get(CRED_ID)?.accountIdentifier).toBeUndefined(); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('Could not read the stored data'), + expect.objectContaining({ credentialId: CRED_ID }), + ); + }); + + it('still reports a connection when its entry holds no token', async () => { + repository.find.mockResolvedValueOnce([makeEntry(CRED_ID, 'user-1', 'encrypted')]); + cipher.decryptV2.mockResolvedValueOnce(JSON.stringify({ apiKey: 'k' })); + + const result = await service.findMyConnections('user-1', [CRED_ID]); + + expect(result.has(CRED_ID)).toBe(true); + expect(result.get(CRED_ID)?.accountIdentifier).toBeUndefined(); + }); + }); + // ─────────────────────────── early-exit guards ──────────────────────────── describe('cleanupOrphanedEntriesForUsers', () => { diff --git a/packages/cli/src/modules/dynamic-credentials.ee/services/credential-connection-status.service.ts b/packages/cli/src/modules/dynamic-credentials.ee/services/credential-connection-status.service.ts index f9d43941ff9..585cfb65bbb 100644 --- a/packages/cli/src/modules/dynamic-credentials.ee/services/credential-connection-status.service.ts +++ b/packages/cli/src/modules/dynamic-credentials.ee/services/credential-connection-status.service.ts @@ -1,3 +1,4 @@ +import { Logger } from '@n8n/backend-common'; import { In, ProjectRelationRepository, @@ -8,8 +9,14 @@ import { import { Service } from '@n8n/di'; import { hasGlobalScope } from '@n8n/permissions'; import type { EntityManager } from '@n8n/typeorm'; +import { Cipher } from 'n8n-core'; +import { jsonParse } from 'n8n-workflow'; -import type { ICredentialConnectionStatusProvider } from '@/credentials/credential-connection-status-provider.interface'; +import type { + ICredentialConnectionStatusProvider, + UserConnection, +} from '@/credentials/credential-connection-status-provider.interface'; +import { extractAccountIdentifierFromData } from '@/oauth/account-identifier'; import { RoleService } from '@/services/role.service'; import { SYSTEM_RESOLVER_ID } from '../constants'; @@ -25,9 +32,9 @@ const CREDENTIAL_RETAIN_SCOPE = 'credential:connect' as const; const keyOf = (pair: CredentialUserPair) => `${pair.credentialId}|${pair.userId}`; /** - * Returns the set of credential ids for which a given user has a per-user - * storage entry under the system resolver. Existence is the signal — no - * decryption is performed. + * Reports which credentials a given user has a per-user storage entry for under + * the system resolver, and which provider account each of those entries + * authenticates as. * * Scoped to the system resolver ({@link SYSTEM_RESOLVER_ID}) because that is * the only resolver used to record per-user OAuth connections today. Entries @@ -44,13 +51,18 @@ export class CredentialConnectionStatusService implements ICredentialConnectionS private readonly sharedCredentialsRepository: SharedCredentialsRepository, private readonly roleService: RoleService, private readonly projectRelationRepository: ProjectRelationRepository, + private readonly cipher: Cipher, + private readonly logger: Logger, ) {} - async findConnectedCredentialIds(userId: string, credentialIds: string[]): Promise> { - if (credentialIds.length === 0) return new Set(); + async findMyConnections( + userId: string, + credentialIds: string[], + ): Promise> { + if (credentialIds.length === 0) return new Map(); const rows = await this.repository.find({ - select: ['credentialId'], + select: ['credentialId', 'data'], where: { userId, resolverId: SYSTEM_RESOLVER_ID, @@ -58,12 +70,54 @@ export class CredentialConnectionStatusService implements ICredentialConnectionS }, }); - return new Set(rows.map((row) => row.credentialId)); + return new Map( + await Promise.all( + rows.map( + async (row) => + [ + row.credentialId, + { accountIdentifier: await this.readAccountIdentifier(row) }, + ] as const, + ), + ), + ); + } + + /** + * The provider account (e.g. the Gmail address) this connection authenticates + * as, read out of the stored token. Derived on read rather than denormalized so + * it stays right after a "Switch account" and needs no backfill for connections + * made before it was surfaced. + * + * Never throws — a connection we cannot label is still a connection. The two + * reasons differ in severity, so they are kept apart: unreadable stored data + * means the connection is broken for execution too and is worth a warning, + * whereas a token carrying no identity claim is routine (Gmail asks for no + * identity scope) and stays silent. + */ + private async readAccountIdentifier( + row: Pick, + ): Promise { + let stored: Record; + try { + stored = jsonParse>(await this.cipher.decryptV2(row.data)); + } catch (error) { + // Only a failed decrypt or a corrupt payload reaches here. The resolver + // reads the same row the same way when the credential is used, so this + // connection will fail at execution — surface it rather than hide it. + this.logger.warn('Could not read the stored data of a per-user credential connection', { + credentialId: row.credentialId, + error, + }); + return undefined; + } + + return extractAccountIdentifierFromData(stored); } /** * Deletes the running user's connection row(s) for the given credential. - * Scoped to the system resolver to mirror {@link findConnectedCredentialIds}. + * Scoped to the system resolver to mirror {@link findMyConnections}. * Returns the number of rows deleted. */ async deleteMyConnection(userId: string, credentialId: string): Promise { diff --git a/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts b/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts index e7ee0afe604..ce0d7d50ceb 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts @@ -1872,15 +1872,12 @@ export class InstanceAiAdapterService { // Fallback for legacy credentials: oauthTokenData is blanked by // redaction, so we need unredacted access here only. const raw = await credentialsService.decrypt(credential, true); - const tokenData = raw.oauthTokenData; - if (tokenData && typeof tokenData === 'object') { - const { OauthService } = await import('@/oauth/oauth.service.js'); - const identifier = OauthService.extractAccountIdentifier( - tokenData as Record, - ); - if (identifier) { - return { accountIdentifier: mask(identifier) }; - } + const { extractAccountIdentifierFromData } = await import( + '@/oauth/account-identifier.js' + ); + const identifier = extractAccountIdentifierFromData(raw); + if (identifier) { + return { accountIdentifier: mask(identifier) }; } return { accountIdentifier: undefined }; diff --git a/packages/cli/src/oauth/__tests__/account-identifier.test.ts b/packages/cli/src/oauth/__tests__/account-identifier.test.ts new file mode 100644 index 00000000000..099d876cbf8 --- /dev/null +++ b/packages/cli/src/oauth/__tests__/account-identifier.test.ts @@ -0,0 +1,91 @@ +import { extractAccountIdentifier, extractAccountIdentifierFromData } from '../account-identifier'; + +describe('extractAccountIdentifier', () => { + it('returns email from direct token field', () => { + expect(extractAccountIdentifier({ email: 'user@example.com', access_token: 'tok' })).toBe( + 'user@example.com', + ); + }); + + it('returns login from direct token field (GitHub-style)', () => { + expect(extractAccountIdentifier({ login: 'octocat', access_token: 'tok' })).toBe('octocat'); + }); + + it('extracts email from JWT id_token', () => { + const payload = { email: 'user@gmail.com', sub: '123' }; + const idToken = `header.${Buffer.from(JSON.stringify(payload)).toString('base64url')}.sig`; + expect(extractAccountIdentifier({ id_token: idToken })).toBe('user@gmail.com'); + }); + + it('extracts preferred_username from JWT id_token when no email', () => { + const payload = { preferred_username: 'admin@contoso.com', sub: '123' }; + const idToken = `header.${Buffer.from(JSON.stringify(payload)).toString('base64url')}.sig`; + expect(extractAccountIdentifier({ id_token: idToken })).toBe('admin@contoso.com'); + }); + + it('falls back to the display name when only the profile scope was granted', () => { + // Google returns no `email` claim unless the email scope was requested. + const payload = { sub: '10464857079', name: 'Ada Lovelace', given_name: 'Ada' }; + const idToken = `header.${Buffer.from(JSON.stringify(payload)).toString('base64url')}.sig`; + expect(extractAccountIdentifier({ id_token: idToken })).toBe('Ada Lovelace'); + }); + + it('never labels a connection with the opaque subject id', () => { + const payload = { sub: '10464857079', iss: 'https://accounts.google.com' }; + const idToken = `header.${Buffer.from(JSON.stringify(payload)).toString('base64url')}.sig`; + expect(extractAccountIdentifier({ id_token: idToken })).toBeUndefined(); + }); + + it('returns undefined for token data without identifiers', () => { + expect(extractAccountIdentifier({ access_token: 'tok', refresh_token: 'ref' })).toBeUndefined(); + }); + + it('returns undefined for a Google token granted only resource scopes', () => { + // A Drive-scoped grant carries no id_token at all. + expect( + extractAccountIdentifier({ + access_token: 'ya29.tok', + refresh_token: '1//ref', + scope: 'https://www.googleapis.com/auth/drive', + token_type: 'Bearer', + callbackQueryString: { iss: 'https://accounts.google.com' }, + }), + ).toBeUndefined(); + }); + + it('handles malformed JWT gracefully', () => { + expect(extractAccountIdentifier({ id_token: 'not.a.jwt' })).toBeUndefined(); + }); + + it('prefers direct fields over id_token', () => { + const payload = { email: 'jwt@example.com' }; + const idToken = `h.${Buffer.from(JSON.stringify(payload)).toString('base64url')}.s`; + expect(extractAccountIdentifier({ email: 'direct@example.com', id_token: idToken })).toBe( + 'direct@example.com', + ); + }); + + it('falls back to the Slack-style authed_user id', () => { + expect(extractAccountIdentifier({ authed_user: { id: 'U123' } })).toBe('U123'); + }); +}); + +describe('extractAccountIdentifierFromData', () => { + it('reads the identifier off a payload carrying a token', () => { + expect( + extractAccountIdentifierFromData({ + clientId: 'abc', + oauthTokenData: { email: 'user@example.com' }, + }), + ).toBe('user@example.com'); + }); + + it.each([ + ['no payload', undefined], + ['a payload with no token', { clientId: 'abc' }], + ['a token that is not an object', { oauthTokenData: 'redacted' }], + ['a null token', { oauthTokenData: null }], + ])('returns undefined for %s', (_case, data) => { + expect(extractAccountIdentifierFromData(data)).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/oauth/__tests__/oauth.service.test.ts b/packages/cli/src/oauth/__tests__/oauth.service.test.ts index 7084f387aa6..8c5bd874aad 100644 --- a/packages/cli/src/oauth/__tests__/oauth.service.test.ts +++ b/packages/cli/src/oauth/__tests__/oauth.service.test.ts @@ -5204,52 +5204,6 @@ describe('OauthService', () => { }); }); - describe('extractAccountIdentifier', () => { - it('returns email from direct token field', () => { - expect( - OauthService.extractAccountIdentifier({ email: 'user@example.com', access_token: 'tok' }), - ).toBe('user@example.com'); - }); - - it('returns login from direct token field (GitHub-style)', () => { - expect(OauthService.extractAccountIdentifier({ login: 'octocat', access_token: 'tok' })).toBe( - 'octocat', - ); - }); - - it('extracts email from JWT id_token', () => { - const payload = { email: 'user@gmail.com', sub: '123' }; - const idToken = `header.${Buffer.from(JSON.stringify(payload)).toString('base64url')}.sig`; - expect(OauthService.extractAccountIdentifier({ id_token: idToken })).toBe('user@gmail.com'); - }); - - it('extracts preferred_username from JWT id_token when no email', () => { - const payload = { preferred_username: 'admin@contoso.com', sub: '123' }; - const idToken = `header.${Buffer.from(JSON.stringify(payload)).toString('base64url')}.sig`; - expect(OauthService.extractAccountIdentifier({ id_token: idToken })).toBe( - 'admin@contoso.com', - ); - }); - - it('returns undefined for token data without identifiers', () => { - expect( - OauthService.extractAccountIdentifier({ access_token: 'tok', refresh_token: 'ref' }), - ).toBeUndefined(); - }); - - it('handles malformed JWT gracefully', () => { - expect(OauthService.extractAccountIdentifier({ id_token: 'not.a.jwt' })).toBeUndefined(); - }); - - it('prefers direct fields over id_token', () => { - const payload = { email: 'jwt@example.com' }; - const idToken = `h.${Buffer.from(JSON.stringify(payload)).toString('base64url')}.s`; - expect( - OauthService.extractAccountIdentifier({ email: 'direct@example.com', id_token: idToken }), - ).toBe('direct@example.com'); - }); - }); - describe('refreshOAuth2CredentialById', () => { const credentialId = 'cred-123'; const projectId = 'proj-456'; diff --git a/packages/cli/src/oauth/account-identifier.ts b/packages/cli/src/oauth/account-identifier.ts new file mode 100644 index 00000000000..63fc5804ff5 --- /dev/null +++ b/packages/cli/src/oauth/account-identifier.ts @@ -0,0 +1,59 @@ +import { jsonParse } from 'n8n-workflow'; + +/** + * Best-effort extraction of the provider-side account an OAuth token belongs to + * (an email, login, display name or user id), used to label a connection in the + * UI as "Connected as …". Providers expose this inconsistently and many expose it + * not at all — `undefined` is an expected result, not a failure. What comes back + * is only ever a label; nothing keys off it. + */ +export function extractAccountIdentifier(tokenData: Record): string | undefined { + for (const key of ['email', 'login', 'username', 'user', 'account']) { + if (typeof tokenData[key] === 'string' && tokenData[key]) { + return tokenData[key]; + } + } + + if (typeof tokenData.id_token === 'string') { + const parts = tokenData.id_token.split('.'); + if (parts.length === 3) { + // A malformed JWT just means no identity, so fall through rather than throw. + const payload = jsonParse>( + Buffer.from(parts[1], 'base64url').toString(), + { fallbackValue: {} }, + ); + // Standard OIDC claims, most to least precise. `name` is the display name + // a `profile`-scoped grant returns when `email` was not requested — worth + // showing, unlike the opaque `sub`, which we deliberately never use. + for (const claim of ['email', 'preferred_username', 'name']) { + if (typeof payload[claim] === 'string' && payload[claim]) { + return payload[claim]; + } + } + } + } + + const authedUser = tokenData.authed_user; + if (authedUser && typeof authedUser === 'object') { + const user = authedUser as Record; + if (typeof user.id === 'string' && user.id) { + return user.id; + } + } + + return undefined; +} + +/** + * {@link extractAccountIdentifier} for a decrypted credential payload, read off its + * `oauthTokenData` if it has one. Callers reach this from three different sources — + * a per-user storage entry, a credential blob, an in-flight update — so the guard + * and the cast live here once rather than at each call site. + */ +export function extractAccountIdentifierFromData( + data: Record | undefined, +): string | undefined { + const tokenData = data?.oauthTokenData; + if (!tokenData || typeof tokenData !== 'object') return undefined; + return extractAccountIdentifier(tokenData as Record); +} diff --git a/packages/cli/src/oauth/oauth.service.ts b/packages/cli/src/oauth/oauth.service.ts index a4bc79e4e7d..b231cc4b1fa 100644 --- a/packages/cli/src/oauth/oauth.service.ts +++ b/packages/cli/src/oauth/oauth.service.ts @@ -26,6 +26,7 @@ import { AuthError } from '@/errors/response-errors/auth.error'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import type { OAuthRequest } from '@/requests'; +import { extractAccountIdentifierFromData } from '@/oauth/account-identifier'; import { validateOAuthUrl } from '@/oauth/validate-oauth-url'; import { UrlService } from '@/services/url.service'; import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data'; @@ -397,13 +398,9 @@ export class OauthService { toUpdate: ICredentialDataDecryptedObject, toDelete: string[] = [], ) { - if (toUpdate.oauthTokenData && typeof toUpdate.oauthTokenData === 'object') { - const identifier = OauthService.extractAccountIdentifier( - toUpdate.oauthTokenData as Record, - ); - if (identifier) { - toUpdate.accountIdentifier = identifier; - } + const identifier = extractAccountIdentifierFromData(toUpdate); + if (identifier) { + toUpdate.accountIdentifier = identifier; } const credentials = new Credentials(credential, credential.type, credential.data); @@ -414,41 +411,6 @@ export class OauthService { }); } - static extractAccountIdentifier(tokenData: Record): string | undefined { - for (const key of ['email', 'login', 'username', 'user', 'account']) { - if (typeof tokenData[key] === 'string' && tokenData[key]) { - return tokenData[key]; - } - } - - if (typeof tokenData.id_token === 'string') { - const parts = tokenData.id_token.split('.'); - if (parts.length === 3) { - try { - const payload: Record = JSON.parse( - Buffer.from(parts[1], 'base64url').toString(), - ); - if (typeof payload.email === 'string' && payload.email) { - return payload.email; - } - if (typeof payload.preferred_username === 'string' && payload.preferred_username) { - return payload.preferred_username; - } - } catch {} - } - } - - const authedUser = tokenData.authed_user; - if (authedUser && typeof authedUser === 'object') { - const user = authedUser as Record; - if (typeof user.id === 'string' && user.id) { - return user.id; - } - } - - return undefined; - } - /** Get a credential without user check */ protected async getCredentialWithoutUser( credentialId: string, diff --git a/packages/cli/test/integration/credentials/credentials.resolvable.api.test.ts b/packages/cli/test/integration/credentials/credentials.resolvable.api.test.ts index fedf72363b9..d534f2fe646 100644 --- a/packages/cli/test/integration/credentials/credentials.resolvable.api.test.ts +++ b/packages/cli/test/integration/credentials/credentials.resolvable.api.test.ts @@ -8,6 +8,7 @@ import { } from '@n8n/backend-test-utils'; import type { Project, User } from '@n8n/db'; import { Container } from '@n8n/di'; +import { Cipher } from 'n8n-core'; import { SYSTEM_RESOLVER_ID, @@ -94,6 +95,16 @@ const seedUserEntry = async (credentialId: string, userId: string) => { ); }; +/** Seeds a connection the way the OAuth callback does: an encrypted token blob. */ +const seedConnectedUserEntry = async ( + credentialId: string, + userId: string, + oauthTokenData: Record, +) => { + const encrypted = await Container.get(Cipher).encryptV2(JSON.stringify({ oauthTokenData })); + await storage.setCredentialData(credentialId, userId, SYSTEM_RESOLVER_ID, encrypted, {}); +}; + const saveResolvableCredential = async () => await saveCredential(randomCredentialPayload({ isResolvable: true }), { project: teamProject, @@ -116,6 +127,7 @@ type CredentialResponseFields = { id: string; isResolvable?: boolean; connectedByMe?: boolean; + connectedAccountIdentifier?: string; data?: { oauthTokenData?: unknown }; }; @@ -146,6 +158,44 @@ describe('GET /credentials — connectedByMe', () => { expect(cred?.connectedByMe).toBe(false); }); + test('reports the provider account my own connection authenticates as', async () => { + const resolvable = await saveResolvableCredential(); + await seedConnectedUserEntry(resolvable.id, memberA.id, { email: 'connected@gmail.com' }); + + const response = await testServer.authAgentFor(memberA).get('/credentials').expect(200); + + const cred = (response.body.data as CredentialResponseFields[]).find( + (c) => c.id === resolvable.id, + ); + expect(cred?.connectedAccountIdentifier).toBe('connected@gmail.com'); + }); + + test('never reports the connected account of another user', async () => { + const resolvable = await saveResolvableCredential(); + await seedConnectedUserEntry(resolvable.id, memberA.id, { email: 'connected@gmail.com' }); + await seedConnectedUserEntry(resolvable.id, memberB.id, { email: 'other@gmail.com' }); + + const response = await testServer.authAgentFor(memberB).get('/credentials').expect(200); + + const cred = (response.body.data as CredentialResponseFields[]).find( + (c) => c.id === resolvable.id, + ); + expect(cred?.connectedAccountIdentifier).toBe('other@gmail.com'); + }); + + test('reports a connection without an account when the token carries no identity', async () => { + const resolvable = await saveResolvableCredential(); + await seedConnectedUserEntry(resolvable.id, memberA.id, { access_token: 'tok' }); + + const response = await testServer.authAgentFor(memberA).get('/credentials').expect(200); + + const cred = (response.body.data as CredentialResponseFields[]).find( + (c) => c.id === resolvable.id, + ); + expect(cred?.connectedByMe).toBe(true); + expect(cred?.connectedAccountIdentifier).toBeUndefined(); + }); + test('omits connectedByMe for static credentials', async () => { const staticCred = await saveStaticCredential(); @@ -255,6 +305,21 @@ describe('GET /credentials/:id — per-user oauthTokenData', () => { expect(bodyB.data?.oauthTokenData).toBeUndefined(); }); + test('reports the connected account the credential modal reads on open', async () => { + const resolvable = await saveResolvableCredential(); + await seedConnectedUserEntry(resolvable.id, memberA.id, { email: 'connected@gmail.com' }); + + const response = await testServer + .authAgentFor(memberA) + .get(`/credentials/${resolvable.id}`) + .query({ includeData: true }) + .expect(200); + + expect((response.body.data as CredentialResponseFields).connectedAccountIdentifier).toBe( + 'connected@gmail.com', + ); + }); + test('preserves static oauthTokenData behavior (shared across all readers)', async () => { const staticCred = await saveStaticCredentialWithOauthTokenData(); diff --git a/packages/frontend/editor-ui/src/Interface.ts b/packages/frontend/editor-ui/src/Interface.ts index f65ebe52fda..3f947557bdb 100644 --- a/packages/frontend/editor-ui/src/Interface.ts +++ b/packages/frontend/editor-ui/src/Interface.ts @@ -351,6 +351,7 @@ export type CredentialsResource = BaseResource & { isGlobal?: boolean; isResolvable?: boolean; connectedByMe?: boolean; + connectedAccountIdentifier?: string; }; // Base resource types that are always available diff --git a/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialConfig.test.ts b/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialConfig.test.ts index 44ce62295de..455d1d0cf17 100644 --- a/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialConfig.test.ts +++ b/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialConfig.test.ts @@ -598,6 +598,61 @@ describe('CredentialConfig', () => { expect(emitted().oauth).toBeTruthy(); }); + describe('connected account label', () => { + it('names the provider account an end-user credential is connected as', () => { + renderComponent({ + props: { + ...oAuthConnectedProps, + isPrivateCredentialsEnabled: true, + isResolvable: true, + connectedByMe: true, + connectedAccountIdentifier: 'jane@gmail.com', + }, + }); + + expect(screen.getByTestId('oauth-connect-success-banner')).toHaveTextContent( + 'Connected as jane@gmail.com', + ); + }); + + it('stays generic rather than naming the n8n account when the provider tells us none', () => { + renderComponent({ + pinia: createTestingPinia({ + initialState: { + [STORES.USERS]: { + currentUserId: 'user-1', + usersById: { 'user-1': { id: 'user-1', email: 'signed-in@n8n.io' } }, + }, + }, + }), + props: { + ...oAuthConnectedProps, + isPrivateCredentialsEnabled: true, + isResolvable: true, + connectedByMe: true, + }, + }); + + const banner = screen.getByTestId('oauth-connect-success-banner'); + expect(banner).toHaveTextContent('Account connected'); + expect(banner).not.toHaveTextContent('signed-in@n8n.io'); + }); + + it('names the provider account a fixed credential is connected as', () => { + renderComponent({ + props: { + ...oAuthConnectedProps, + isResolvable: false, + credentialData: { accountIdentifier: 'octocat' } as ICredentialDataDecryptedObject, + }, + }); + + expect(screen.getByTestId('oauth-connect-success-banner')).toHaveTextContent( + 'Connected as octocat', + ); + }); + }); + it('shows stale-connection actions and emits their events on auth error', async () => { const { emitted } = renderComponent({ props: { diff --git a/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialConfig.vue b/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialConfig.vue index 0646b568b91..a2249013e2c 100644 --- a/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialConfig.vue +++ b/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialConfig.vue @@ -27,7 +27,6 @@ import { useCredentialsStore } from '../../credentials.store'; import { injectNDVStore } from '@/features/ndv/shared/ndv.store'; import { useRootStore } from '@n8n/stores/useRootStore'; import { useUIStore } from '@/app/stores/ui.store'; -import { useUsersStore } from '@n8n/stores/users.store'; import Banner from '@/app/components/Banner.vue'; import CopyInput from '@/app/components/CopyInput.vue'; import CredentialInputs from './CredentialInputs.vue'; @@ -78,6 +77,8 @@ type Props = { isPrivateCredentialsEnabled?: boolean; isResolvable?: boolean; connectedByMe?: boolean; + /** Provider account of the caller's own connection, for end-user credentials. */ + connectedAccountIdentifier?: string; isNewCredential?: boolean; managedOauthAvailable?: boolean; useCustomOauth?: boolean; @@ -96,6 +97,7 @@ const props = withDefaults(defineProps(), { authError: '', showValidationWarning: false, credentialPermissions: () => ({}) as PermissionsRecord['credential'], + connectedAccountIdentifier: undefined, instanceAiCredentialHelp: undefined, }); const emit = defineEmits<{ @@ -114,7 +116,6 @@ const credentialsStore = useCredentialsStore(); const ndvStore = injectNDVStore(); const rootStore = useRootStore(); const uiStore = useUIStore(); -const usersStore = useUsersStore(); const workflowDocumentStore = injectWorkflowDocumentStore(); const assistantStore = useAssistantStore(); const chatPanelStore = useChatPanelStore(); @@ -233,13 +234,14 @@ const isConnectedOAuth = computed( // expired). In this state we promote Switch account over the plain Retry button. const isStale = computed(() => isConnectedOAuth.value && !!props.authError); -// The connected account label: for end-user creds it's the current user's own -// connection; for fixed creds it's the identifier derived from the stored token -// (may be absent for providers that don't return one — then fall back to a -// generic "Account connected" message). +// The connected account label — always the provider account the token belongs to, +// never the n8n account. For end-user creds it comes from the caller's own per-user +// connection, for fixed creds from the token stored on the credential. Many +// providers return no identity at all (Gmail asks for no identity scope), so an +// absent value is normal and falls back to a generic "Account connected" message. const connectedAccountName = computed(() => { if (props.isResolvable) { - return usersStore.currentUser?.email ?? undefined; + return props.connectedAccountIdentifier; } const identifier = props.credentialData?.accountIdentifier; return typeof identifier === 'string' && identifier ? identifier : undefined; diff --git a/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialEdit.vue b/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialEdit.vue index b1dbc8de75e..a1a8b588a4f 100644 --- a/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialEdit.vue +++ b/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialEdit.vue @@ -234,6 +234,7 @@ const { showValidationWarning, isResolvable, connectedByMe, + connectedAccountIdentifier, useCustomOAuth, activeNodeType, credentialTypeName, @@ -596,6 +597,7 @@ async function onResolvableChange(value: boolean) { // doesn't apply to the new mode, and `oauthTokenData` (mirrored true for a connected // end-user credential) would otherwise be read as "connected" once static. connectedByMe.value = false; + connectedAccountIdentifier.value = undefined; credentialData.value = { ...credentialData.value, oauthTokenData: null as unknown as CredentialInformation, @@ -1160,8 +1162,15 @@ async function oAuthCredentialAuthorize() { connectedByMe.value = true; - void credentialsStore.fetchAllCredentials().then(() => { + void credentialsStore.fetchAllCredentials().then((credentials) => { nodeHelpers.updateNodesCredentialsIssues(); + // The account just connected is only known server-side, so pick it up + // from the refresh rather than guessing at who the user is. Read this + // request's own response, not the store: any other credentials fetch + // that resolves later replaces the whole store map with its own view. + connectedAccountIdentifier.value = credentials.find( + (credential) => credential.id === credentialId.value, + )?.connectedAccountIdentifier; }); // Close the window @@ -1225,6 +1234,7 @@ async function onDisconnectMyConnection(): Promise { } else { await credentialsStore.disconnectOauthToken({ id: credentialId.value }); } + connectedAccountIdentifier.value = undefined; credentialData.value = { ...credentialData.value, oauthTokenData: null as unknown as CredentialInformation, @@ -1270,6 +1280,7 @@ async function onAuthTypeChanged(payload: CredentialModeOption): Promise { // this session (or carried over from the loaded credential) makes the banner // misreport "connected" for a mode that was never actually saved. connectedByMe.value = false; + connectedAccountIdentifier.value = undefined; credentialData.value = { ...credentialData.value, oauthTokenData: null as unknown as CredentialInformation, @@ -1435,6 +1446,7 @@ const { width } = useElementSize(credNameRef); :is-private-credentials-enabled="isPrivateCredentialsEnabled && !isInstanceCredential" :is-resolvable="isResolvable" :connected-by-me="connectedByMe" + :connected-account-identifier="connectedAccountIdentifier" :is-new-credential="isNewCredential" :managed-oauth-available="managedOAuthAvailable" :use-custom-oauth="useCustomOAuth" diff --git a/packages/frontend/editor-ui/src/features/credentials/components/NodeCredentials.test.ts b/packages/frontend/editor-ui/src/features/credentials/components/NodeCredentials.test.ts index 5a7e7474de0..c73a63e1c13 100644 --- a/packages/frontend/editor-ui/src/features/credentials/components/NodeCredentials.test.ts +++ b/packages/frontend/editor-ui/src/features/credentials/components/NodeCredentials.test.ts @@ -22,6 +22,8 @@ import { useUIStore } from '@/app/stores/ui.store'; import { useSettingsStore } from '@n8n/stores/settings.store'; import { useNodeTypesStore } from '@/app/stores/nodeTypes.store'; import { useWorkflowsStore } from '@/app/stores/workflows.store'; +import { useUsersStore } from '@n8n/stores/users.store'; +import type { IUser } from '@n8n/rest-api-client/api/users'; import { useAiGateway } from '@/app/composables/useAiGateway'; import { ChatHubToolContextKey, WorkflowDocumentStoreKey } from '@/app/constants/injectionKeys'; import { @@ -2529,6 +2531,37 @@ describe('NodeCredentials', () => { expect(screen.queryByTestId('node-credential-private-connect')).not.toBeInTheDocument(); }); + it('names the provider account the connection authenticates as', async () => { + credentialsStore.state.credentials = { + 'private-cred-id': { + ...privateCredential, + connectedByMe: true, + connectedAccountIdentifier: 'jane@gmail.com', + }, + }; + renderComponent({ props: { node: notionNode, overrideCredType: 'openAiApi' } }); + + expect(screen.getByTestId('node-credential-private-row')).toHaveTextContent( + 'Connected as jane@gmail.com', + ); + }); + + it('does not name the n8n account when the provider tells us no account', async () => { + const usersStore = mockedStore(useUsersStore); + usersStore.usersById = { + 'user-1': { id: 'user-1', email: 'signed-in@n8n.io' } as IUser, + }; + usersStore.currentUserId = 'user-1'; + credentialsStore.state.credentials = { + 'private-cred-id': { ...privateCredential, connectedByMe: true }, + }; + renderComponent({ props: { node: notionNode, overrideCredType: 'openAiApi' } }); + + const row = screen.getByTestId('node-credential-private-row'); + expect(row).toHaveTextContent('Connected'); + expect(row).not.toHaveTextContent('signed-in@n8n.io'); + }); + it('shows the Connect button when connectedByMe is false', async () => { credentialsStore.state.credentials = { 'private-cred-id': { ...privateCredential, connectedByMe: false }, diff --git a/packages/frontend/editor-ui/src/features/credentials/components/NodeCredentials.vue b/packages/frontend/editor-ui/src/features/credentials/components/NodeCredentials.vue index 7b36314899f..ee9507b9a37 100644 --- a/packages/frontend/editor-ui/src/features/credentials/components/NodeCredentials.vue +++ b/packages/frontend/editor-ui/src/features/credentials/components/NodeCredentials.vue @@ -40,7 +40,6 @@ import { useNodeTypesStore } from '@/app/stores/nodeTypes.store'; import { useUIStore } from '@/app/stores/ui.store'; import { useProjectsStore } from '@/features/collaboration/projects/projects.store'; import { useWorkflowsStore } from '@/app/stores/workflows.store'; -import { useUsersStore } from '@n8n/stores/users.store'; import { assert } from '@n8n/utils/assert'; import { isEmpty } from '@/app/utils/typesUtils'; import { getResourcePermissions } from '@n8n/permissions'; @@ -141,7 +140,6 @@ const ndvStore = injectNDVStoreIfProvided(); const uiStore = useUIStore(); const projectsStore = useProjectsStore(); const workflowsStore = useWorkflowsStore(); -const usersStore = useUsersStore(); const workflowDocumentStore = props.standalone ? undefined : injectWorkflowDocumentStore(); const { isEnabled: isPrivateCredentialsEnabled } = usePrivateCredentials(); @@ -244,7 +242,21 @@ async function onConnectFromRow(credentialType: string): Promise { if (!credential) return; const success = await authorize(credential); if (success) { - credentialsStore.setConnectedByMe(credential.id, true); + credentialsStore.setConnectedByMe(credential.id, true, await fetchMyAccount(credential.id)); + } +} + +/** + * The provider account a fresh connection authenticates as, which only the + * server can tell us. Purely a label — a failed lookup must not undo a + * successful connection. + */ +async function fetchMyAccount(credentialId: string): Promise { + try { + const credential = await credentialsStore.getCredentialData({ id: credentialId }); + return credential?.connectedAccountIdentifier; + } catch { + return undefined; } } @@ -1191,7 +1203,9 @@ async function onQuickConnectSignIn(credentialTypeName: string) { :credential-type-name="type.name" :credential-name="getServiceName(type.name)" :is-connected="isPrivateConnected(type.name)" - :connected-account-name="usersStore.currentUser?.email ?? undefined" + :connected-account-name=" + getSelectedPrivateCredential(type.name)?.connectedAccountIdentifier + " :can-connect="canConnectPrivateCredential(type.name)" data-test-id="node-credential-private-row" @connect="onConnectFromRow(type.name)" diff --git a/packages/frontend/editor-ui/src/features/credentials/composables/useCredentialForm.ts b/packages/frontend/editor-ui/src/features/credentials/composables/useCredentialForm.ts index 94296928d97..e8220f49eff 100644 --- a/packages/frontend/editor-ui/src/features/credentials/composables/useCredentialForm.ts +++ b/packages/frontend/editor-ui/src/features/credentials/composables/useCredentialForm.ts @@ -105,6 +105,8 @@ export function useCredentialForm(options: UseCredentialFormOptions) { const showValidationWarning = ref(false); const isResolvable = ref(false); const connectedByMe = ref(false); + /** The provider account my own connection authenticates as, when the provider tells us. */ + const connectedAccountIdentifier = ref(undefined); const useCustomOAuth = ref(false); // --- type resolution --------------------------------------------------- @@ -479,6 +481,11 @@ export function useCredentialForm(options: UseCredentialFormOptions) { 'connectedByMe' in loaded && typeof loaded.connectedByMe === 'boolean' ? loaded.connectedByMe : false; + connectedAccountIdentifier.value = + 'connectedAccountIdentifier' in loaded && + typeof loaded.connectedAccountIdentifier === 'string' + ? loaded.connectedAccountIdentifier + : undefined; } // An existing credential whose managed clientId/secret were overridden was @@ -642,6 +649,7 @@ export function useCredentialForm(options: UseCredentialFormOptions) { showValidationWarning, isResolvable, connectedByMe, + connectedAccountIdentifier, useCustomOAuth, // derived activeNodeType, diff --git a/packages/frontend/editor-ui/src/features/credentials/credentials.store.ts b/packages/frontend/editor-ui/src/features/credentials/credentials.store.ts index 38fd42d7e60..642c5e41cc7 100644 --- a/packages/frontend/editor-ui/src/features/credentials/credentials.store.ts +++ b/packages/frontend/editor-ui/src/features/credentials/credentials.store.ts @@ -416,12 +416,21 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, () => { await credentialsApi.disconnectOauthToken(rootStore.restApiContext, id); }; - const setConnectedByMe = (id: string, connectedByMe: boolean) => { + /** + * Mirrors the caller's own connection state locally. The account identifier is + * only known server-side, so it is cleared unless one is passed in — better no + * label than a stale one from the previously connected account. + */ + const setConnectedByMe = ( + id: string, + connectedByMe: boolean, + connectedAccountIdentifier?: string, + ) => { const existing = state.value.credentials[id]; if (existing) { state.value.credentials = { ...state.value.credentials, - [id]: { ...existing, connectedByMe }, + [id]: { ...existing, connectedByMe, connectedAccountIdentifier }, }; } }; diff --git a/packages/frontend/editor-ui/src/features/credentials/credentials.types.ts b/packages/frontend/editor-ui/src/features/credentials/credentials.types.ts index 6d02a2ca1d8..9cd47a2a1f5 100644 --- a/packages/frontend/editor-ui/src/features/credentials/credentials.types.ts +++ b/packages/frontend/editor-ui/src/features/credentials/credentials.types.ts @@ -19,6 +19,12 @@ export interface ICredentialsResponse extends ICredentialsEncrypted { usageScope?: 'project' | 'instance'; /** Whether the current user has personally connected this credential. Set on resolvable credentials only. */ connectedByMe?: boolean; + /** + * The provider account the current user's own connection authenticates as + * (e.g. the connected Gmail address). Set on resolvable credentials only, and + * absent whenever the provider returns no identity claim. + */ + connectedAccountIdentifier?: string; /** Total number of users connected to this credential. Set on resolvable credentials only. */ connectedUserCount?: number; } @@ -42,6 +48,12 @@ export interface ICredentialsDecryptedResponse extends ICredentialsBase, ICreden isResolvable?: boolean; /** Whether the current user has personally connected this credential. Set on resolvable credentials only. */ connectedByMe?: boolean; + /** + * The provider account the current user's own connection authenticates as + * (e.g. the connected Gmail address). Set on resolvable credentials only, and + * absent whenever the provider returns no identity claim. + */ + connectedAccountIdentifier?: string; /** Total number of users connected to this credential. Set on resolvable credentials only. */ connectedUserCount?: number; }