mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
fix(editor): Show the connected provider account, not the n8n account email (#35553)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7352eb7def
commit
ee03e023d2
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1249,16 +1249,21 @@ describe('CredentialsService', () => {
|
|||
transactionManager.findOneBy.mockResolvedValue(credential);
|
||||
setRepositoryTransaction(transactionManager);
|
||||
const encrypted = mock<ICredentialsDb>({ 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;
|
||||
|
|
|
|||
|
|
@ -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<Set<string>>;
|
||||
findMyConnections(userId: string, credentialIds: string[]): Promise<Map<string, UserConnection>>;
|
||||
|
||||
/**
|
||||
* Returns the number of distinct users who have a per-user entry for this
|
||||
|
|
|
|||
|
|
@ -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<Set<string>> {
|
||||
if (!this.provider || credentialIds.length === 0) return new Set();
|
||||
return await this.provider.findConnectedCredentialIds(userId, credentialIds);
|
||||
async findMyConnections(
|
||||
userId: string,
|
||||
credentialIds: string[],
|
||||
): Promise<Map<string, UserConnection>> {
|
||||
if (!this.provider || credentialIds.length === 0) return new Map();
|
||||
return await this.provider.findMyConnections(userId, credentialIds);
|
||||
}
|
||||
|
||||
async countConnectedUsers(credentialId: string): Promise<number> {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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<number> {
|
||||
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<T extends { id: string; isResolvable?: boolean }>(
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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<SharedCredentialsRepository>();
|
||||
const roleService = mock<RoleService>();
|
||||
const projectRelationRepository = mock<ProjectRelationRepository>();
|
||||
const cipher = mock<Cipher>();
|
||||
const logger = mock<Logger>();
|
||||
const em = mock<EntityManager>();
|
||||
|
||||
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<string, unknown>) =>
|
||||
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', () => {
|
||||
|
|
|
|||
|
|
@ -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<Set<string>> {
|
||||
if (credentialIds.length === 0) return new Set();
|
||||
async findMyConnections(
|
||||
userId: string,
|
||||
credentialIds: string[],
|
||||
): Promise<Map<string, UserConnection>> {
|
||||
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<DynamicCredentialUserEntry, 'credentialId' | 'data'>,
|
||||
): Promise<string | undefined> {
|
||||
let stored: Record<string, unknown>;
|
||||
try {
|
||||
stored = jsonParse<Record<string, unknown>>(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<number> {
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>,
|
||||
);
|
||||
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 };
|
||||
|
|
|
|||
91
packages/cli/src/oauth/__tests__/account-identifier.test.ts
Normal file
91
packages/cli/src/oauth/__tests__/account-identifier.test.ts
Normal file
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
59
packages/cli/src/oauth/account-identifier.ts
Normal file
59
packages/cli/src/oauth/account-identifier.ts
Normal file
|
|
@ -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, unknown>): 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<Record<string, unknown>>(
|
||||
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<string, unknown>;
|
||||
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<string, unknown> | undefined,
|
||||
): string | undefined {
|
||||
const tokenData = data?.oauthTokenData;
|
||||
if (!tokenData || typeof tokenData !== 'object') return undefined;
|
||||
return extractAccountIdentifier(tokenData as Record<string, unknown>);
|
||||
}
|
||||
|
|
@ -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<string, unknown>,
|
||||
);
|
||||
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, unknown>): 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<string, unknown> = 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<string, unknown>;
|
||||
if (typeof user.id === 'string' && user.id) {
|
||||
return user.id;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Get a credential without user check */
|
||||
protected async getCredentialWithoutUser(
|
||||
credentialId: string,
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>,
|
||||
) => {
|
||||
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();
|
||||
|
||||
|
|
|
|||
|
|
@ -351,6 +351,7 @@ export type CredentialsResource = BaseResource & {
|
|||
isGlobal?: boolean;
|
||||
isResolvable?: boolean;
|
||||
connectedByMe?: boolean;
|
||||
connectedAccountIdentifier?: string;
|
||||
};
|
||||
|
||||
// Base resource types that are always available
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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<Props>(), {
|
|||
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<string | undefined>(() => {
|
||||
if (props.isResolvable) {
|
||||
return usersStore.currentUser?.email ?? undefined;
|
||||
return props.connectedAccountIdentifier;
|
||||
}
|
||||
const identifier = props.credentialData?.accountIdentifier;
|
||||
return typeof identifier === 'string' && identifier ? identifier : undefined;
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
|||
} 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<void> {
|
|||
// 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"
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
|||
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<string | undefined> {
|
||||
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)"
|
||||
|
|
|
|||
|
|
@ -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<string | undefined>(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,
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user