Merge branch 'main' into feat/graphql-relation-traversal-filters-frontend
This commit is contained in:
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "twenty-client-sdk",
|
||||
"version": "2.4.2",
|
||||
"version": "2.5.0",
|
||||
"sideEffects": false,
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
|
||||
+25
-4
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "twenty-sdk",
|
||||
"version": "2.4.2",
|
||||
"version": "2.5.0",
|
||||
"sideEffects": false,
|
||||
"bin": {
|
||||
"twenty": "dist/cli.cjs"
|
||||
|
||||
+50
-12
@@ -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;
|
||||
|
||||
+1
-1
@@ -7,4 +7,4 @@
|
||||
* |___/
|
||||
*/
|
||||
|
||||
export const TWENTY_CURRENT_VERSION = '2.5.0' as const;
|
||||
export const TWENTY_CURRENT_VERSION = '2.6.0' as const;
|
||||
|
||||
+3
-1
@@ -7,4 +7,6 @@
|
||||
* |___/
|
||||
*/
|
||||
|
||||
export const TWENTY_NEXT_VERSIONS = ['2.6.0'] as const;
|
||||
export const TWENTY_NEXT_VERSIONS = [
|
||||
'2.7.0',
|
||||
] as const;
|
||||
|
||||
+1
@@ -16,4 +16,5 @@ export const TWENTY_PREVIOUS_VERSIONS = [
|
||||
'2.2.0',
|
||||
'2.3.0',
|
||||
'2.4.0',
|
||||
'2.5.0',
|
||||
] as const;
|
||||
|
||||
+61
-42
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+20
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user