Merge branch 'main' into feat/graphql-relation-traversal-filters-frontend

This commit is contained in:
Félix Malfait
2026-05-14 20:37:53 +02:00
committed by GitHub
10 changed files with 164 additions and 63 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "create-twenty-app",
"version": "2.4.2",
"version": "2.5.0",
"description": "Command-line interface to create Twenty application",
"main": "dist/cli.cjs",
"bin": "dist/cli.cjs",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "twenty-client-sdk",
"version": "2.4.2",
"version": "2.5.0",
"sideEffects": false,
"license": "AGPL-3.0",
"scripts": {
@@ -1,35 +1,56 @@
import { useAuth } from '@/auth/hooks/useAuth';
import { useHasAccessTokenPair } from '@/auth/hooks/useHasAccessTokenPair';
import {
SignInUpStep,
signInUpStepState,
} from '@/auth/states/signInUpStepState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { useLoadCurrentUser } from '@/users/hooks/useLoadCurrentUser';
import { useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { isDefined } from 'twenty-shared/utils';
export const SignInUpGlobalScopeFormEffect = () => {
const setSignInUpStep = useSetAtomState(signInUpStepState);
const signInUpStep = useAtomStateValue(signInUpStepState);
const [searchParams, setSearchParams] = useSearchParams();
const { setAuthTokens } = useAuth();
const { loadCurrentUser } = useLoadCurrentUser();
const hasAccessTokenPair = useHasAccessTokenPair();
useEffect(() => {
const tokenPair = searchParams.get('tokenPair');
if (isDefined(tokenPair)) {
setAuthTokens(JSON.parse(tokenPair));
// Path 1: user just bounced back from social SSO with a workspace-agnostic
// tokenPair in the URL. Honor it unconditionally.
const tokenPairFromUrl = searchParams.get('tokenPair');
if (isDefined(tokenPairFromUrl)) {
setAuthTokens(JSON.parse(tokenPairFromUrl));
searchParams.delete('tokenPair');
setSearchParams(searchParams);
loadCurrentUser();
setSignInUpStep(SignInUpStep.WorkspaceSelection);
return;
}
// Path 2: user revisits /welcome with a still-valid workspace-agnostic
// tokenPair cookie left over from a prior SSO landing. Resume straight
// to workspace selection instead of forcing them through the sign-in
// form again. The step transition gates re-entry; if the cookie is
// stale, loadCurrentUser triggers Apollo's renewal -> onUnauthenticatedError
// path which clears the cookie and falls back to the normal form.
if (signInUpStep !== SignInUpStep.Init) return;
if (!hasAccessTokenPair) return;
loadCurrentUser();
setSignInUpStep(SignInUpStep.WorkspaceSelection);
}, [
searchParams,
setSearchParams,
setSignInUpStep,
loadCurrentUser,
setAuthTokens,
signInUpStep,
hasAccessTokenPair,
]);
return <></>;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "twenty-sdk",
"version": "2.4.2",
"version": "2.5.0",
"sideEffects": false,
"bin": {
"twenty": "dist/cli.cjs"
@@ -1,3 +1,5 @@
import { Logger } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { DataSource, QueryRunner } from 'typeorm';
@@ -12,30 +14,47 @@ const VALUE_CHECK_CONSTRAINT_NAME = 'CHK_applicationVariable_value_encrypted';
const V2_ENCRYPTED_LIKE_PATTERN = `${SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX}%`;
// Legacy CTR ciphertext is base64-encoded and at least 16 bytes (one IV
// block) — i.e. ≥ 22 base64 chars. Anything outside that shape is plaintext.
// Node's `Buffer.from(value, 'base64')` silently skips invalid chars, so a
// URL like `https://hooks.slack.com/...` would otherwise decode into enough
// bytes to "decrypt" to garbage without throwing.
const LEGACY_CTR_LOOKS_LIKE_BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
const LEGACY_CTR_MIN_LENGTH = 22;
type ApplicationVariableRow = {
id: string;
workspaceId: string;
value: string;
isSecret: boolean;
};
const looksLikeLegacyCtrCiphertext = (value: string): boolean =>
value.length >= LEGACY_CTR_MIN_LENGTH &&
LEGACY_CTR_LOOKS_LIKE_BASE64_RE.test(value);
@RegisteredInstanceCommand('2.5.0', 1798000005000, { type: 'slow' })
export class EncryptApplicationVariableSlowInstanceCommand
implements SlowInstanceCommand
{
private readonly logger = new Logger(
EncryptApplicationVariableSlowInstanceCommand.name,
);
constructor(
private readonly secretEncryptionService: SecretEncryptionService,
) {}
// Re-encrypts every secret application variable into the versioned envelope
// bound to its row's workspaceId. Non-secret rows are left untouched —
// their `value` is plaintext by design. Idempotent: the SELECT filter
// skips rows already in v2 form.
// Re-encrypts secret application variables into the v2 envelope. Rows
// marked isSecret=true with a plaintext value (instead of legacy CTR
// ciphertext) are treated as plaintext and encrypted, mirroring
// EncryptConnectedAccountTokensSlowInstanceCommand.
async runDataMigration(dataSource: DataSource): Promise<void> {
let cursor = '00000000-0000-0000-0000-000000000000';
while (true) {
const rows: ApplicationVariableRow[] = await dataSource.query(
`SELECT id, "workspaceId", "value"
`SELECT id, "workspaceId", "value", "isSecret"
FROM "core"."applicationVariable"
WHERE id > $1
AND "isSecret" = true
@@ -51,13 +70,32 @@ export class EncryptApplicationVariableSlowInstanceCommand
}
for (const row of rows) {
// decryptVersioned handles legacy unprefixed CTR ciphertext by
// falling through to the raw-key decrypt path — exactly what we
// need to read the pre-migration rows.
const plaintext = this.secretEncryptionService.decryptVersioned(
row.value,
{ workspaceId: row.workspaceId },
);
if (!row.isSecret) {
continue;
}
let plaintext: string;
if (looksLikeLegacyCtrCiphertext(row.value)) {
try {
plaintext = this.secretEncryptionService.decryptVersioned(
row.value,
{ workspaceId: row.workspaceId },
);
} catch (error) {
this.logger.warn(
`applicationVariable row ${row.id} value not valid ciphertext; treating as plaintext. ${
error instanceof Error ? error.message : String(error)
}`,
);
plaintext = row.value;
}
} else {
this.logger.warn(
`applicationVariable row ${row.id} value is not base64; treating as plaintext.`,
);
plaintext = row.value;
}
if (!isDefined(plaintext)) {
continue;
@@ -7,4 +7,4 @@
* |___/
*/
export const TWENTY_CURRENT_VERSION = '2.5.0' as const;
export const TWENTY_CURRENT_VERSION = '2.6.0' as const;
@@ -7,4 +7,6 @@
* |___/
*/
export const TWENTY_NEXT_VERSIONS = ['2.6.0'] as const;
export const TWENTY_NEXT_VERSIONS = [
'2.7.0',
] as const;
@@ -16,4 +16,5 @@ export const TWENTY_PREVIOUS_VERSIONS = [
'2.2.0',
'2.3.0',
'2.4.0',
'2.5.0',
] as const;
@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import chunk from 'lodash.chunk';
import { isDefined } from 'twenty-shared/utils';
import { In, IsNull, type QueryRunner, Repository } from 'typeorm';
@@ -21,6 +22,8 @@ export type WorkspaceLastAttemptedCommand = {
isInitial: boolean;
};
const UPGRADE_MIGRATION_SAVE_BATCH_SIZE = 1000;
@Injectable()
export class UpgradeMigrationService {
constructor(
@@ -95,7 +98,7 @@ export class UpgradeMigrationService {
where: { name, workspaceId: IsNull() },
});
await repository.save([
const instanceRows = [
{
name,
status,
@@ -112,7 +115,14 @@ export class UpgradeMigrationService {
workspaceId,
errorMessage,
})),
]);
];
for (const batch of chunk(
instanceRows,
UPGRADE_MIGRATION_SAVE_BATCH_SIZE,
)) {
await repository.save(batch);
}
return;
}
@@ -134,7 +144,9 @@ export class UpgradeMigrationService {
});
}
await repository.save(rows);
for (const batch of chunk(rows, UPGRADE_MIGRATION_SAVE_BATCH_SIZE)) {
await repository.save(batch);
}
}
async markAsWorkspaceInitial({
@@ -219,48 +231,55 @@ export class UpgradeMigrationService {
return new Map();
}
const migrations = await this.upgradeMigrationRepository
.createQueryBuilder('migration')
.select([
'migration.workspaceId',
'migration.name',
'migration.status',
'migration.executedByVersion',
'migration.errorMessage',
'migration.createdAt',
'migration.isInitial',
])
.where({
workspaceId: In(workspaceIds),
})
.andWhere(
`migration.attempt = (
SELECT MAX(sub.attempt)
FROM core."upgradeMigration" sub
WHERE sub.name = migration.name
AND sub."workspaceId" = migration."workspaceId"
)`,
)
.orderBy('migration.workspaceId')
.addOrderBy('migration.createdAt', 'DESC')
.distinctOn(['migration.workspaceId'])
.getMany();
const rows = await this.upgradeMigrationRepository.manager.query<
Array<{
workspaceId: string;
name: string;
status: UpgradeMigrationStatus;
executedByVersion: string;
errorMessage: string | null;
createdAt: Date;
isInitial: boolean;
}>
>(
`
SELECT DISTINCT ON (latest_per_name."workspaceId")
latest_per_name."workspaceId",
latest_per_name.name,
latest_per_name.status,
latest_per_name."executedByVersion",
latest_per_name."errorMessage",
latest_per_name."createdAt",
latest_per_name."isInitial"
FROM (
SELECT DISTINCT ON ("workspaceId", name)
"workspaceId",
name,
status,
"executedByVersion",
"errorMessage",
"createdAt",
"isInitial"
FROM core."upgradeMigration"
WHERE "workspaceId" = ANY($1)
ORDER BY "workspaceId", name, attempt DESC
) latest_per_name
ORDER BY latest_per_name."workspaceId", latest_per_name."createdAt" DESC
`,
[workspaceIds],
);
const cursors = new Map<string, WorkspaceLastAttemptedCommand>();
for (const migration of migrations) {
if (migration.workspaceId === null) {
continue;
}
cursors.set(migration.workspaceId, {
workspaceId: migration.workspaceId,
name: migration.name,
status: migration.status,
executedByVersion: migration.executedByVersion,
errorMessage: migration.errorMessage,
createdAt: migration.createdAt,
isInitial: migration.isInitial,
for (const row of rows) {
cursors.set(row.workspaceId, {
workspaceId: row.workspaceId,
name: row.name,
status: row.status,
executedByVersion: row.executedByVersion,
errorMessage: row.errorMessage,
createdAt: row.createdAt,
isInitial: row.isInitial,
});
}
@@ -164,6 +164,26 @@ describe('2-5 slow instance command 1798000005000 - EncryptApplicationVariableSl
expect(row.value).toBe(plaintext);
});
it('treats plaintext-under-isSecret=true as plaintext and re-encrypts as v2', async () => {
const plaintext =
'https://hooks.slack.com/services/T09QGPB2ZP1/B09QUQ5LY2Z/abc';
const id = await seedRow({ isSecret: true, value: plaintext });
await command.runDataMigration(dataSource);
const [row] = await dataSource.query(
`SELECT "value" FROM "core"."applicationVariable" WHERE id = $1`,
[id],
);
expect(row.value.startsWith(SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX)).toBe(
true,
);
expect(
secretEncryptionService.decryptVersioned(row.value, { workspaceId }),
).toBe(plaintext);
});
it('leaves enc:v2 rows untouched and is idempotent across re-runs', async () => {
const plaintext = 'already-v2-secret';
const preexistingV2 = secretEncryptionService.encryptVersioned(plaintext, {