diff --git a/packages/create-twenty-app/README.md b/packages/create-twenty-app/README.md index 64c8c2b524..9b260f9b02 100644 --- a/packages/create-twenty-app/README.md +++ b/packages/create-twenty-app/README.md @@ -49,7 +49,7 @@ Full documentation is available at **[docs.twenty.com/developers/extend/apps](ht ## Troubleshooting - Server not starting: check Docker is running (`docker info`), then try `yarn twenty docker:logs`. -- Auth not working: run `yarn twenty remote:add --local` to re-authenticate. +- Auth not working: run `yarn twenty remote:add` to re-authenticate. - Types not generated: ensure `yarn twenty dev` is running — it auto-generates the typed client. ## Contributing diff --git a/packages/create-twenty-app/src/create-app.command.ts b/packages/create-twenty-app/src/create-app.command.ts index 0d6291005a..fa5be51ddd 100644 --- a/packages/create-twenty-app/src/create-app.command.ts +++ b/packages/create-twenty-app/src/create-app.command.ts @@ -572,7 +572,7 @@ export class CreateAppCommand { console.log( chalk.yellow( - ' Authentication failed. Run `yarn twenty remote:add --local` manually.', + ' Authentication failed. Run `yarn twenty remote:add` manually.', ), ); @@ -580,7 +580,7 @@ export class CreateAppCommand { } catch { console.log( chalk.yellow( - ' Authentication failed. Run `yarn twenty remote:add --local` manually.', + ' Authentication failed. Run `yarn twenty remote:add` manually.', ), ); diff --git a/packages/twenty-sdk/src/cli/commands/dev/dev.ts b/packages/twenty-sdk/src/cli/commands/dev/dev.ts index 70e6c76412..709f727f88 100644 --- a/packages/twenty-sdk/src/cli/commands/dev/dev.ts +++ b/packages/twenty-sdk/src/cli/commands/dev/dev.ts @@ -1,3 +1,6 @@ +import { ApiService } from '@/cli/utilities/api/api-service'; +import { promptForReauthentication } from '@/cli/utilities/auth/reauth-helper'; +import { ConfigService } from '@/cli/utilities/config/config-service'; import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory'; import { DevModeOrchestrator } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator'; import { OrchestratorState } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state'; @@ -27,6 +30,15 @@ export class AppDevCommand { return this.orchestrator; } + private async ensureAuthenticatedBeforeLaunch(): Promise { + const apiService = new ApiService({ disableInterceptors: true }); + const { serverUp, authValid } = await apiService.validateAuth(); + + if (serverUp && !authValid) { + await promptForReauthentication(ConfigService.getActiveRemote()); + } + } + async execute(options: AppDevOptions): Promise { const appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY; @@ -36,6 +48,8 @@ export class AppDevCommand { await checkServerVersionCompatibility(); } + await this.ensureAuthenticatedBeforeLaunch(); + const orchestratorState = new OrchestratorState({ appPath, }); diff --git a/packages/twenty-sdk/src/cli/operations/dev-once.ts b/packages/twenty-sdk/src/cli/operations/dev-once.ts index 0fbcc870ab..35f583f0b5 100644 --- a/packages/twenty-sdk/src/cli/operations/dev-once.ts +++ b/packages/twenty-sdk/src/cli/operations/dev-once.ts @@ -1,5 +1,5 @@ import path from 'path'; -import { OUTPUT_DIR, type Manifest } from 'twenty-shared/application'; +import { type Manifest, OUTPUT_DIR } from 'twenty-shared/application'; import { type SyncAction } from 'twenty-shared/metadata'; import { ApiService } from '@/cli/utilities/api/api-service'; @@ -7,6 +7,7 @@ import { ensureAppAccessTokenIsValidOrRefresh, ensureAppRegistration, } from '@/cli/utilities/auth'; +import { promptForReauthentication } from '@/cli/utilities/auth/reauth-helper'; import { buildApplication } from '@/cli/utilities/build/common/build-application'; import { runTypecheck } from '@/cli/utilities/build/common/typecheck-plugin'; import { buildAndValidateManifest } from '@/cli/utilities/build/manifest/build-and-validate-manifest'; @@ -81,14 +82,20 @@ const innerAppDevOnce = async ( } if (!validateAuth.authValid) { - return { - success: false, - error: { - code: APP_ERROR_CODES.SYNC_FAILED, - message: - 'Authentication failed. Run `yarn twenty remote:add --local` to authenticate.', - }, - }; + const outcome = await promptForReauthentication( + ConfigService.getActiveRemote(), + ); + + if (outcome !== 'reauthenticated') { + return { + success: false, + error: { + code: APP_ERROR_CODES.SYNC_FAILED, + message: + 'Authentication failed. Run `yarn twenty remote:add` to authenticate.', + }, + }; + } } onProgress?.('Building manifest...'); diff --git a/packages/twenty-sdk/src/cli/utilities/api/api-client.ts b/packages/twenty-sdk/src/cli/utilities/api/api-client.ts index 0f897ba719..050ccb5efd 100644 --- a/packages/twenty-sdk/src/cli/utilities/api/api-client.ts +++ b/packages/twenty-sdk/src/cli/utilities/api/api-client.ts @@ -1,14 +1,21 @@ -import { ConfigService } from '@/cli/utilities/config/config-service'; import { isNonEmptyString } from '@sniptt/guards'; -import axios, { type AxiosInstance } from 'axios'; +import axios, { + type AxiosInstance, + type AxiosResponse, + type InternalAxiosRequestConfig, +} from 'axios'; import chalk from 'chalk'; import { isDefined } from 'twenty-shared/utils'; +import { promptForReauthentication } from '@/cli/utilities/auth/reauth-helper'; +import { ConfigService } from '@/cli/utilities/config/config-service'; + export class ApiClient { readonly client: AxiosInstance; readonly configService: ConfigService; private readonly tokenOverride?: string; readonly serverUrlOverride?: string; + private reauthAttempted: boolean = false; constructor(options?: { disableInterceptors?: boolean; @@ -48,14 +55,51 @@ export class ApiClient { } this.client.interceptors.response.use( - (response) => response, - async (error) => { - if (error.response?.status === 401) { - console.error( - chalk.red( - 'Authentication failed. Run `yarn twenty remote:add` to authenticate.', - ), + async (response) => { + // Handle auth errors returned as GraphQL errors in HTTP 200 responses + if ( + response.status === 200 && + response.data?.errors && + Array.isArray(response.data.errors) + ) { + const hasAuthError = response.data.errors.some( + (error: { message?: string; extensions?: { code?: string } }) => + error.extensions?.code === 'UNAUTHENTICATED' || + error.extensions?.code === 'FORBIDDEN' || + (typeof error.message === 'string' && + error.message.toLowerCase().includes('unauthenticated')), ); + + if (hasAuthError) { + if (!this.reauthAttempted) { + const retried = await this.tryReauthenticateAndRetry( + response.config, + ); + + if (retried) { + return retried; + } + } + + const authError = new Error( + 'Authentication failed: GraphQL auth error in response', + ) as Error & { response: typeof response }; + authError.response = response; + throw authError; + } + } + + return response; + }, + async (error) => { + if (error.response?.status === 401 && error.config) { + if (!this.reauthAttempted) { + const retried = await this.tryReauthenticateAndRetry(error.config); + + if (retried) { + return retried; + } + } } else if (error.response?.status === 403) { console.error( chalk.red( @@ -72,6 +116,30 @@ export class ApiClient { ); } + private async tryReauthenticateAndRetry( + config: InternalAxiosRequestConfig, + ): Promise { + // Prevent recursion: only attempt reauth once per client instance + this.reauthAttempted = true; + + const remoteName = ConfigService.getActiveRemote(); + console.error(`Authentication failed on remote "${remoteName}"`); + + const outcome = await promptForReauthentication(remoteName); + + if (outcome === 'reauthenticated') { + const authToken = await this.resolveAuthToken(); + + if (authToken) { + config.headers.Authorization = `Bearer ${authToken}`; + + return this.client.request(config); + } + } + + return null; + } + async getFrontendUrl(): Promise { try { const response = await this.client.get( diff --git a/packages/twenty-sdk/src/cli/utilities/api/api-response-type.ts b/packages/twenty-sdk/src/cli/utilities/api/api-response-type.ts index a05fee44ce..380b9fc699 100644 --- a/packages/twenty-sdk/src/cli/utilities/api/api-response-type.ts +++ b/packages/twenty-sdk/src/cli/utilities/api/api-response-type.ts @@ -7,6 +7,7 @@ type FailingApiResponse = { success: false; error?: TError; message?: string; + isAuthError?: boolean; }; export type ApiResponse = | SuccessfulApiResponse diff --git a/packages/twenty-sdk/src/cli/utilities/api/file-api.ts b/packages/twenty-sdk/src/cli/utilities/api/file-api.ts index ab25a12f7c..ecd7093908 100644 --- a/packages/twenty-sdk/src/cli/utilities/api/file-api.ts +++ b/packages/twenty-sdk/src/cli/utilities/api/file-api.ts @@ -121,6 +121,14 @@ export class FileApi { }; } catch (error) { if (axios.isAxiosError(error) && error.response) { + if (error.response.status === 401) { + return { + success: false, + error: error.response.data?.errors?.[0]?.message || error.message, + isAuthError: true, + }; + } + return { success: false, error: error.response.data?.errors?.[0]?.message || error.message, diff --git a/packages/twenty-sdk/src/cli/utilities/auth/reauth-helper.ts b/packages/twenty-sdk/src/cli/utilities/auth/reauth-helper.ts new file mode 100644 index 0000000000..31bb202c13 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/auth/reauth-helper.ts @@ -0,0 +1,43 @@ +import inquirer from 'inquirer'; +import chalk from 'chalk'; +import { authLoginOAuth } from '@/cli/operations/login-oauth'; +import { ConfigService } from '@/cli/utilities/config/config-service'; + +export type ReauthOutcome = 'reauthenticated' | 'declined' | 'non-interactive'; + +export const promptForReauthentication = async ( + remoteName: string, +): Promise => { + if (!process.stdout.isTTY) { + return 'non-interactive'; + } + + const { proceed } = await inquirer.prompt<{ proceed: boolean }>([ + { + type: 'confirm', + name: 'proceed', + message: `Re-authenticate remote "${remoteName}" now?`, + default: true, + }, + ]); + + if (!proceed) { + return 'declined'; + } + + const configService = new ConfigService(); + const { apiUrl } = await configService.getConfig(); + + const result = await authLoginOAuth({ apiUrl, remote: remoteName }); + + if (result.success) { + console.log(chalk.green(`✓ Re-authenticated "${remoteName}".`)); + + return 'reauthenticated'; + } + + console.log(chalk.yellow(result.error.message)); + console.log(chalk.yellow('Run `yarn twenty remote:add` to re-authenticate.')); + + return 'declined'; +}; diff --git a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/check-server-orchestrator-step.ts b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/check-server-orchestrator-step.ts index 17127992a6..c3b01c8ca8 100644 --- a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/check-server-orchestrator-step.ts +++ b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/check-server-orchestrator-step.ts @@ -74,7 +74,7 @@ export class CheckServerOrchestratorStep { this.state.applyStepEvents([ { message: - 'Authentication failed. Run `yarn twenty remote:add --local` to authenticate.', + 'Authentication failed. Run `yarn twenty remote:add` to authenticate.', status: 'error', }, ]);