Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions packages/twenty-front/src/generated-metadata/graphql.ts

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export const GET_MY_CONNECTED_ACCOUNTS = gql`
handle
provider
authFailedAt
authFailedReason
archivedAt
scopes
handleAliases
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useMutation, useQuery } from '@apollo/client/react';
import { styled } from '@linaria/react';
import { Trans, useLingui } from '@lingui/react/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { type ReactNode } from 'react';
import { useParams } from 'react-router-dom';
import { SettingsPath } from 'twenty-shared/types';
Expand Down Expand Up @@ -258,6 +259,13 @@ export const SettingsApplicationConnectionDetail = () => {
label: t`Auth failed at`,
value: formatDateTime(connection.authFailedAt),
},
{
key: 'authFailedReason',
label: t`Auth failure reason`,
value: isNonEmptyString(connection.authFailedReason)
? connection.authFailedReason
: '-',
},
{
key: 'createdAt',
label: t`Created`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const buildConnection = (
accessToken: 'token-fresh',
scopes: ['read'],
authFailedAt: null,
authFailedReason: null,
...overrides,
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const buildConnection = (
accessToken: 'fresh',
scopes: ['read'],
authFailedAt: null,
authFailedReason: null,
...overrides,
});

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi,
type MockInstance,
} from 'vitest';

import { reportConnectionAuthFailure } from '@/sdk/logic-function/connections/report-connection-auth-failure';

describe('reportConnectionAuthFailure', () => {
let fetchSpy: MockInstance<typeof fetch>;

beforeEach(() => {
process.env.TWENTY_API_URL = 'https://api.test';
process.env.TWENTY_APP_ACCESS_TOKEN = 'app-token';
fetchSpy = vi.spyOn(globalThis, 'fetch');
});

afterEach(() => {
delete process.env.TWENTY_API_URL;
delete process.env.TWENTY_APP_ACCESS_TOKEN;
fetchSpy.mockRestore();
});

it('sends the mutation with the connection id and reason', async () => {
fetchSpy.mockResolvedValue(
new Response(
JSON.stringify({ data: { reportAppConnectionAuthFailure: true } }),
{ status: 200 },
),
);

await reportConnectionAuthFailure('c-1', 'Slack rejected the token');

const [url, requestInit] = fetchSpy.mock.calls[0];

expect(String(url)).toBe('https://api.test/metadata');

const body = JSON.parse(String(requestInit?.body));

expect(body.query).toContain('reportAppConnectionAuthFailure');
expect(body.variables).toEqual({
input: { id: 'c-1', reason: 'Slack rejected the token' },
});
});

it('omits the reason when none is given', async () => {
fetchSpy.mockResolvedValue(
new Response(
JSON.stringify({ data: { reportAppConnectionAuthFailure: true } }),
{ status: 200 },
),
);

await reportConnectionAuthFailure('c-2');

const body = JSON.parse(String(fetchSpy.mock.calls[0][1]?.body));

expect(body.variables).toEqual({ input: { id: 'c-2' } });
});

it('propagates a GraphQL error', async () => {
fetchSpy.mockResolvedValue(
new Response(
JSON.stringify({ errors: [{ message: 'Connection c-3 not found' }] }),
{ status: 200 },
),
);

await expect(reportConnectionAuthFailure('c-3')).rejects.toThrow(
'Connection c-3 not found',
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const GET_APP_CONNECTION_QUERY = `
accessToken
scopes
authFailedAt
authFailedReason
}
}
`;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const LIST_APP_CONNECTIONS_QUERY = `
accessToken
scopes
authFailedAt
authFailedReason
}
}
`;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { postGraphqlRequest } from '@/sdk/logic-function/utils/post-graphql-request.util';

const REPORT_APP_CONNECTION_AUTH_FAILURE_MUTATION = `
mutation ReportAppConnectionAuthFailure($input: ReportAppConnectionAuthFailureInput!) {
reportAppConnectionAuthFailure(input: $input)
}
`;

// Marks one of the app's connections as auth-failed (`authFailedAt`), with an
// optional human-readable reason shown on the connection row in settings.
// For providers whose tokens the platform never refreshes (a Slack bot token,
// for example), this is the only way a dead credential becomes visible: the
// row flips to "Reconnect needed" and `getConnection` starts throwing
// `AppConnectionAuthFailedError`. The flag clears automatically when the
// user reconnects.
Comment thread
abdulrahmancodes marked this conversation as resolved.
export const reportConnectionAuthFailure = async (
connectionId: string,
reason?: string,
): Promise<void> => {
await postGraphqlRequest<
{ input: { id: string; reason?: string } },
{ reportAppConnectionAuthFailure: boolean }
>({
query: REPORT_APP_CONNECTION_AUTH_FAILURE_MUTATION,
variables: { input: { id: connectionId, reason } },
caller: 'reportConnectionAuthFailure',
});
};
1 change: 1 addition & 0 deletions packages/twenty-sdk/src/sdk/logic-function/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export { getConnection } from '@/sdk/logic-function/connections/get-connection';
export { listConnections } from '@/sdk/logic-function/connections/list-connections';
export type { ListConnectionsFilter } from '@/sdk/logic-function/connections/list-connections';
export { findConnectionForRequest } from '@/sdk/logic-function/connections/find-connection-for-request';
export { reportConnectionAuthFailure } from '@/sdk/logic-function/connections/report-connection-auth-failure';
export { AppConnectionAuthFailedError } from '@/sdk/logic-function/connections/errors/app-connection-auth-failed.error';
export type { AppConnection } from '@/sdk/logic-function/connections/types/app-connection.type';

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { QueryRunner } from 'typeorm';

import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';

@RegisteredInstanceCommand('2.38.0', 1788445931849)
export class AddAuthFailedReasonToConnectedAccountFastInstanceCommand implements FastInstanceCommand {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'ALTER TABLE "core"."connectedAccount" ADD "authFailedReason" text',
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'ALTER TABLE "core"."connectedAccount" DROP COLUMN "authFailedReason"',
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ import { AddStateToApplicationFastInstanceCommand } from 'src/database/commands/
import { RenameEmailingDomainPermanentlySuspendedToSandboxFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-38/2-38-instance-command-fast-1788272351966-rename-emailing-domain-permanently-suspended-to-sandbox';
import { RelaxNavigationPayloadCheckFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-38/2-38-instance-command-fast-1788272351970-relax-navigation-payload-check';
import { EraseObjectNavigationCommandMenuItemPayloadsSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-38/2-38-instance-command-slow-1788272351971-erase-object-navigation-command-menu-item-payloads';
import { AddAuthFailedReasonToConnectedAccountFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-38/2-38-instance-command-fast-1788445931849-add-auth-failed-reason-to-connected-account';

export const INSTANCE_COMMANDS = [
AddViewFieldGroupIdIndexOnViewFieldFastInstanceCommand,
Expand Down Expand Up @@ -348,4 +349,5 @@ export const INSTANCE_COMMANDS = [
RenameEmailingDomainPermanentlySuspendedToSandboxFastInstanceCommand,
RelaxNavigationPayloadCheckFastInstanceCommand,
EraseObjectNavigationCommandMenuItemPayloadsSlowInstanceCommand,
AddAuthFailedReasonToConnectedAccountFastInstanceCommand,
];
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,7 @@ export class ConnectionProviderOAuthFlowService {
scopes: tokenResponse.scopes ?? provider.oauthConfig.scopes,
lastCredentialsRefreshedAt: new Date(),
authFailedAt: null,
authFailedReason: null,
visibility,
handle,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { ConnectionProviderEntity } from 'src/engine/core-modules/application/connection-provider/connection-provider.entity';
import { ApplicationConnectionsController } from 'src/engine/core-modules/application/connection-provider/connections/application-connections.controller';
import { ApplicationConnectionsResolver } from 'src/engine/core-modules/application/connection-provider/connections/application-connections.resolver';
import { ApplicationConnectionAuthFailureService } from 'src/engine/core-modules/application/connection-provider/connections/services/application-connection-auth-failure.service';
import { ApplicationConnectionsListService } from 'src/engine/core-modules/application/connection-provider/connections/services/application-connections-list.service';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
Expand Down Expand Up @@ -33,6 +34,7 @@ import { RefreshTokensManagerModule } from 'src/modules/connected-account/refres
ConnectedAccountTokenEncryptionModule,
],
providers: [
ApplicationConnectionAuthFailureService,
ApplicationConnectionsListService,
ApplicationConnectionsResolver,
],
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { UseGuards } from '@nestjs/common';
import { Args, ID, Query } from '@nestjs/graphql';
import { Args, ID, Mutation, Query } from '@nestjs/graphql';

import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { AppConnectionObjectDto } from 'src/engine/core-modules/application/connection-provider/connections/dtos/app-connection.object';
import { ListAppConnectionsInput } from 'src/engine/core-modules/application/connection-provider/connections/dtos/list-app-connections.input';
import { ReportAppConnectionAuthFailureInput } from 'src/engine/core-modules/application/connection-provider/connections/dtos/report-app-connection-auth-failure.input';
import { ApplicationConnectionAuthFailureService } from 'src/engine/core-modules/application/connection-provider/connections/services/application-connection-auth-failure.service';
import { ApplicationConnectionsListService } from 'src/engine/core-modules/application/connection-provider/connections/services/application-connections-list.service';
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
Expand All @@ -18,6 +20,7 @@ import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
export class ApplicationConnectionsResolver {
constructor(
private readonly listService: ApplicationConnectionsListService,
private readonly authFailureService: ApplicationConnectionAuthFailureService,
) {}

@Query(() => [AppConnectionObjectDto])
Expand Down Expand Up @@ -51,4 +54,23 @@ export class ApplicationConnectionsResolver {
id,
});
}

@Mutation(() => Boolean)
async reportAppConnectionAuthFailure(
@AuthApplication() application: FlatApplication,
@AuthWorkspace() workspace: FlatWorkspace,
@AuthUserWorkspaceId({ allowUndefined: true })
userWorkspaceId: string | undefined,
@Args('input') input: ReportAppConnectionAuthFailureInput,
): Promise<boolean> {
await this.authFailureService.reportAuthFailure({
applicationId: application.id,
workspaceId: workspace.id,
requestUserWorkspaceId: userWorkspaceId ?? null,
id: input.id,
reason: input.reason ?? null,
});

return true;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,7 @@ export class AppConnectionObjectDto implements AppConnection {

@Field(() => String, { nullable: true })
authFailedAt: string | null;

@Field(() => String, { nullable: true })
authFailedReason: string | null;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Field, ID, InputType } from '@nestjs/graphql';

import { IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';

@InputType('ReportAppConnectionAuthFailureInput')
export class ReportAppConnectionAuthFailureInput {
@IsUUID()
@Field(() => ID)
id: string;

@IsString()
@IsOptional()
@MaxLength(1000)
@Field({ nullable: true })
reason?: string;
Comment thread
abdulrahmancodes marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';

import { Repository } from 'typeorm';

import { ConnectedAccountProvider } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';

import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';

type ReportAuthFailureArgs = {
applicationId: string;
workspaceId: string;
requestUserWorkspaceId: string | null;
id: string;
reason: string | null;
};

// Lets an app mark one of its own connections as auth-failed, for providers
// whose tokens never go through the platform refresh flow (a revoked Slack
// bot token, for example, only ever fails at call time inside the app).
// The reconnect flow clears the flag the same way it does for refresh
// failures.
@Injectable()
export class ApplicationConnectionAuthFailureService {
constructor(
@InjectRepository(ConnectedAccountEntity)
private readonly connectedAccountRepository: Repository<ConnectedAccountEntity>,
) {}

async reportAuthFailure({
applicationId,
workspaceId,
requestUserWorkspaceId,
id,
reason,
}: ReportAuthFailureArgs): Promise<void> {
const account = await this.connectedAccountRepository.findOne({
where: {
id,
applicationId,
workspaceId,
provider: ConnectedAccountProvider.APP,
},
});

if (!isDefined(account)) {
throw new NotFoundException(`Connection ${id} not found`);
}

// Same privacy rule as reading a connection: a request-user can only act
// on their own user-visibility credentials.
if (
Comment thread
abdulrahmancodes marked this conversation as resolved.
isDefined(requestUserWorkspaceId) &&
account.visibility === 'user' &&
account.userWorkspaceId !== requestUserWorkspaceId
) {
throw new NotFoundException(`Connection ${id} not found`);
}

await this.connectedAccountRepository.update(
Comment thread
abdulrahmancodes marked this conversation as resolved.
Outdated
{ id: account.id, workspaceId },
{ authFailedAt: new Date(), authFailedReason: reason },
Comment thread
abdulrahmancodes marked this conversation as resolved.
);
Comment thread
abdulrahmancodes marked this conversation as resolved.
Outdated
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ export class ApplicationConnectionsListService {
}),
scopes: account.scopes ?? provider.oauthConfig?.scopes ?? [],
authFailedAt: account.authFailedAt?.toISOString() ?? null,
authFailedReason: account.authFailedReason,
};
} catch (error) {
this.logger.warn(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export class UpdateConnectedAccountOnReconnectService {
refreshToken: encryptedRefreshToken,
scopes,
authFailedAt: null,
authFailedReason: null,
archivedAt: null,
},
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ export class ConnectedAccountDTO {
@Field(() => Date, { nullable: true })
authFailedAt: Date | null;

@IsString()
@IsOptional()
@Field(() => String, { nullable: true })
authFailedReason: string | null;

// Set when the account is frozen after its owner is removed from the
// workspace: synced data is kept but the account is read-only.
@IsDateString()
Expand Down
Loading
Loading