Compare commits

...
Author SHA1 Message Date
Paul Rastoinandprastoin e891e13e64 Application file storage service (#20793)
# Introduction
Fix unsafe resource path join with expected prefix at file storage
directly
Add early paths transversal detections in metadata validators
2026-05-21 11:36:50 +02:00
Charles BochetandGitHub e6b7f31fea fix(front): prevent standalone page layout crash from useTargetRecord (#20698)
## Context

Reported in production on `engineering.twenty.com` — standalone page
layouts (e.g. "Release overview") crash with the React error boundary
fallback ("Sorry, something went wrong"). The console shows:

```
Error: useTargetRecord must be used within a record page context (targetRecordIdentifier is required)
```

The minified stack trace points at `SidePanelToggleButto…`, but that's
just the bundle chunk name — the actual call site is
`PageLayoutTabsRenderer`.

## Root cause

#19296 added an unconditional `useTargetRecord()` call inside
`PageLayoutTabsRenderer` so it could read the target object's metadata
and hide tabs whose widgets reference deactivated relations:

```ts
const targetRecord = useTargetRecord();

const { objectMetadataItem } = useObjectMetadataItem({
  objectNameSingular: targetRecord.targetObjectNameSingular,
});
```

But `PageLayoutTabsRenderer` runs on **both** record pages and
standalone pages. On standalone pages, `StandalonePageLayoutPage`
intentionally sets `targetRecordIdentifier: undefined` in
`LayoutRenderingProvider`, which makes `useTargetRecord()` throw — and
the follow-up `useObjectMetadataItem()` would also throw on miss.
2026-05-19 00:40:37 +02:00
Charles BochetandGitHub bad1f20012 fix(server): handle legacy PK name in 2.6 rename-permission-flag upgrade (#20697)
## Summary

The 2.6 `RenamePermissionFlagToRolePermissionFlag` upgrade command
failed on staging and dev with:

```
[QueryFailedError] constraint "PK_a02789db60620a1e9f90147b50f" for table "rolePermissionFlag" does not exist
in RenamePermissionFlagToRolePermissionFlag1778235340020 (2.6.0) (instance fast)
```

### Root cause

TypeORM names PKs as `PK_<sha1(tableName_sortedColumnNames)[:27]>`. So:
- `permissionFlag_id` → `PK_a02789db60620a1e9f90147b50f`
- `settingPermission_id` → `PK_8c144a021030d7e3326835a04c8`
- `rolePermissionFlag_id` → `PK_76591adc8035c2e7b0cd6115136`

On databases initially migrated before the v1.5.5 migration squash
(#15183), the table was renamed `settingPermission` → `permissionFlag`
via the pre-squash migration
`1753149175945-renameSettingPermissionToPermissionFlag.ts`. That
migration renamed the table, the column, the unique index, and the role
FK, but **never renamed the PK constraint** — and Postgres does not
auto-rename constraints on `ALTER TABLE ... RENAME TO`. Those instances
therefore still carry the legacy PK name
`PK_8c144a021030d7e3326835a04c8`.

Fresh installs (squashed `setupMetadataTables` migration) instead have
the expected `PK_a02789db60620a1e9f90147b50f`.

The 2.6 upgrade only handled the fresh-install name, so it broke for any
DB that went through the historical rename chain.

### Fix

Replace the brittle `RENAME CONSTRAINT` with `DROP CONSTRAINT IF EXISTS`
for both historical PK names, followed by `ADD CONSTRAINT ... PRIMARY
KEY ("id")` with the canonical new name. The migration now converges to
the same PK name regardless of the DB's history.

The same pattern is applied symmetrically in `down()`.

### Why this is safe

- The whole instance command runs in a transaction
(`InstanceCommandRunnerService.runFastInstanceCommand`).
- The first statement (`ALTER TABLE ... RENAME TO`) takes `ACCESS
EXCLUSIVE` on the table, so the drop/add window for the PK is invisible
to any concurrent writer — they queue on the lock until commit.
- No FK references `rolePermissionFlag.id` at this point in the sequence
(migration 22 introduces an FK pointing at the new `permissionFlag`
catalog created in migration 21, not at the renamed grant table), so
dropping the PK does not cascade or block.
- `NOT NULL` and the `uuid_generate_v4()` default on `id` are
column-level and remain in place when the PK is dropped.

## Test plan

- [ ] Run 2.6 upgrade against a fresh-install database (PK =
`PK_a02789db60620a1e9f90147b50f`) — should succeed.
- [ ] Run 2.6 upgrade against a pre-squash database (PK =
`PK_8c144a021030d7e3326835a04c8`, reproducible on current staging/dev) —
should now succeed.
- [ ] Verify post-migration: `rolePermissionFlag` exists, PK is named
`PK_76591adc8035c2e7b0cd6115136`, all FKs and indexes named as expected.
- [ ] Run `down()` and verify table returns to `permissionFlag` with PK
`PK_a02789db60620a1e9f90147b50f`.
- [ ] Subsequent migrations (`1778235340021` permission-flag catalog,
`1778235340022` link, `1778235340023` backfill) still apply cleanly.
2026-05-18 23:04:15 +02:00
ce8ef261c1 Update pricing plan cards (#20614)
## Summary
- Update pricing top-card bullets to use workflow credits, keep full
customization, and show Organization-only features accurately.
- Make custom AI models self-host Organization-only and move custom
domain into the Cloud Organization card.
- Align pricing comparison rows for row-level permissions, encryption
key rotation, API call limits, custom domain availability, and self-host
custom objects/fields.

## Validation
- `lingui extract --overwrite --clean`
- `lingui compile --typescript`
- `node scripts/check-section-shape.mjs`
- `git diff --check`
- Playwright snapshot of `http://localhost:3002/pricing`

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-05-18 19:43:15 +00:00
Charles BochetandGitHub 1d3d3999e2 feat(server): upgrade-aware entity decorators for cross-version upgrades (#20686)
## What

When the same PR introduces a new core entity *and* adds a cache
provider that queries it, every workspace step from older versions that
runs before the introducing instance step hits `relation … does not
exist` — the cause of the failed [v2.6.0 staging-ci
run](https://github.com/twentyhq/twenty-infra/actions/runs/26042742000).
Same class of failure for renamed core entities and for new FK columns
hidden inside relation loads.

This PR adds **upgrade-aware entity decorators** + a runtime that adapts
TypeORM's view of the schema to the current `core.upgradeMigration`
cursor.

## Strategy

```
                    ┌────────────────────────────────┐
                    │  @Entity classes (final shape) │
                    │   + @WasIntroducedInUpgrade    │
                    │   + @WasRenamedInUpgrade       │
                    └───────────────┬────────────────┘
                                    │
              UpgradeSequenceRunner.run()
              ┌─────────────────────┴─────────────────────┐
              ▼                                           ▼
       step N+1 begins                          step N just completed
              │                                           │
              └────────► adapter.refresh() ◄──────────────┘
                            │
            reads core.upgradeMigration via
            UpgradeMigrationService.getLastAttemptedInstanceCommand
                            │
                            ▼
       ┌────────────────────────────────────────────────────────┐
       │  UpgradeAwareEntityMetadataAdapter                     │
       │  • mutates EntityMetadata.tableName / tablePath        │
       │     -> historical name for renames not yet applied     │
       │  • flips column.isSelect = false for not-yet-introduced│
       │     columns                                            │
       │  • tracks per-entity availability sidecar              │
       └─────────────────┬──────────────────────────────────────┘
                         │
                         ▼
       DataSource.getRepository wrapped at TypeOrmModule.forRoot:
       repo.find() / findOne() / count() / …
       ┌─────────────────────────────────────────┐
       │  wrapRepositoryWithUpgradeAwareProxy    │
       │  • entity unavailable -> short-circuit  │
       │     (find -> [], count -> 0,            │
       │      findOneOrFail -> EntityNotFound)   │
       │  • write -> Promise.reject(             │
       │      UpgradeUnavailableEntityWriteEx)   │
       │  • find({ relations: ['X'] }) with X    │
       │     unavailable -> X stripped           │
       └─────────────────────────────────────────┘
```

The decorator strings reference real `core.upgradeMigration.name` values
(`${version}_${className}_${timestamp}`). A boot-time validator walks
the actual `UpgradeSequenceReaderService.getUpgradeSequence()` and fails
fast on typos.

## Files

- New decorators:
`engine/core-modules/upgrade/decorators/was-introduced-in-upgrade.decorator.ts`,
`was-renamed-in-upgrade.decorator.ts`
- Runtime: `engine/twenty-orm/upgrade-aware/` (adapter, proxy, install
hook, state singleton, exceptions)
- Wired into `UpgradeSequenceRunnerService` (`refresh()` between steps)
and `TypeOrmModule.forRoot` (proxy install)
- 2-6 entity decorations: `RolePermissionFlagEntity` (rename history +
new `permissionFlagId` column), `PermissionFlagEntity` (new catalog)

## Validation

End-to-end local cross-version upgrade (v1.22 → HEAD): `28 workspace(s)
succeeded, 0 failed`; `upgrade:status → Instance: Up to date, 4 up to
date, 0 behind, 0 failed`. Full log excerpts and the
second-failure-found-and-fixed (`WorkspaceRolesPermissionsCacheService`
relation load) in [this
comment](https://github.com/twentyhq/twenty/pull/20686#issuecomment-4480036816).

## Test plan

- [x] Adapter spec covers rename mutation; proxy spec covers `find()`
short-circuit on unavailable entity. Resolver + validator + decorators
are covered by `resolve-entity-shape-at-upgrade-cursor.util.spec.ts`
(integration-level via real decorator application).
- [x] `nx lint:diff-with-main twenty-server` + `nx typecheck
twenty-server` clean
- [x] All 82 affected tests passing
- [ ] Cross-version-upgrade CI re-runs after this lands; v2.6.0 retag
once green

## Follow-ups deferred

- v2.7 `connectionProvider` rename repro as a permanent end-to-end test
artifact
- Extending the proxy to also cover `EntityManager.getRepository` and
`createQueryBuilder` if a non-`find()` upgrade-time consumer surfaces
2026-05-18 21:38:20 +02:00
EtienneandGitHub 89579f5225 fix(ai-chat) - upload files (#20681)
closes https://github.com/twentyhq/twenty/issues/20437

bonus : persist file filename for UI display
2026-05-18 15:50:02 +00:00
132d997474 i18n - translations (#20685)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-05-18 17:42:30 +02:00
7c252ff233 Fix 19026 deactivated relation unassignable (#19296)
PR to fix the bug #19026 

This PR will ensure that if an object has some relation deactivated, the
relation will not be visible in the side panel tab and will not be
assignable in the deactivated relation.

## Notes deactivated in People
<img width="1068" height="1106" alt="image"
src="https://github.com/user-attachments/assets/e8c2dbf3-5391-4dbc-8e40-79fcc44e8158"
/>

## Notes not visible in the side panel of people
<img width="2390" height="892" alt="image"
src="https://github.com/user-attachments/assets/308a78aa-2c6d-4d3d-b67c-b49795839aae"
/>

---------

Co-authored-by: Thomas Trompette <thomas.trompette@sfr.fr>
2026-05-18 15:25:51 +00:00
nitinandGitHub 4ba9c0ca0b [Navigation Drawer] Multiple fixes in settings and app drawer (#20634)
closes -
https://discord.com/channels/1130383047699738754/1487720717192527942



https://github.com/user-attachments/assets/6db2df8b-be01-4b5f-a958-575d87b41559

~~waiting on @Bonapara 's feedback!~~
2026-05-18 15:22:03 +00:00
134 changed files with 3658 additions and 384 deletions
@@ -3263,7 +3263,7 @@ type Mutation {
updateWebhook(input: UpdateWebhookInput!): Webhook!
deleteWebhook(id: UUID!): Webhook!
createChatThread: AgentChatThread!
sendChatMessage(threadId: UUID!, text: String!, messageId: UUID!, browsingContext: JSON, modelId: String, fileIds: [UUID!]): SendChatMessageResult!
sendChatMessage(threadId: UUID!, text: String!, messageId: UUID!, browsingContext: JSON, modelId: String, fileAttachments: [FileAttachmentInput!]): SendChatMessageResult!
stopAgentChatStream(threadId: UUID!): Boolean!
renameChatThread(id: UUID!, title: String!): AgentChatThread!
archiveChatThread(id: UUID!): AgentChatThread!
@@ -4228,6 +4228,11 @@ input UpdateWebhookInputUpdates {
secret: String
}
input FileAttachmentInput {
id: UUID!
filename: String!
}
input CreateSkillInput {
id: UUID
name: String!
@@ -5872,7 +5872,7 @@ export interface MutationGenqlSelection{
updateWebhook?: (WebhookGenqlSelection & { __args: {input: UpdateWebhookInput} })
deleteWebhook?: (WebhookGenqlSelection & { __args: {id: Scalars['UUID']} })
createChatThread?: AgentChatThreadGenqlSelection
sendChatMessage?: (SendChatMessageResultGenqlSelection & { __args: {threadId: Scalars['UUID'], text: Scalars['String'], messageId: Scalars['UUID'], browsingContext?: (Scalars['JSON'] | null), modelId?: (Scalars['String'] | null), fileIds?: (Scalars['UUID'][] | null)} })
sendChatMessage?: (SendChatMessageResultGenqlSelection & { __args: {threadId: Scalars['UUID'], text: Scalars['String'], messageId: Scalars['UUID'], browsingContext?: (Scalars['JSON'] | null), modelId?: (Scalars['String'] | null), fileAttachments?: (FileAttachmentInput[] | null)} })
stopAgentChatStream?: { __args: {threadId: Scalars['UUID']} }
renameChatThread?: (AgentChatThreadGenqlSelection & { __args: {id: Scalars['UUID'], title: Scalars['String']} })
archiveChatThread?: (AgentChatThreadGenqlSelection & { __args: {id: Scalars['UUID']} })
@@ -6259,6 +6259,8 @@ update: UpdateWebhookInputUpdates}
export interface UpdateWebhookInputUpdates {targetUrl?: (Scalars['String'] | null),operations?: (Scalars['String'][] | null),description?: (Scalars['String'] | null),secret?: (Scalars['String'] | null)}
export interface FileAttachmentInput {id: Scalars['UUID'],filename: Scalars['String']}
export interface CreateSkillInput {id?: (Scalars['UUID'] | null),name: Scalars['String'],label: Scalars['String'],icon?: (Scalars['String'] | null),description?: (Scalars['String'] | null),content: Scalars['String']}
export interface UpdateSkillInput {id: Scalars['UUID'],name?: (Scalars['String'] | null),label?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),description?: (Scalars['String'] | null),content?: (Scalars['String'] | null),isActive?: (Scalars['Boolean'] | null)}
@@ -78,8 +78,8 @@ export default {
335,
342,
378,
454,
466
455,
467
],
"types": {
"BillingProductDTO": {
@@ -7876,9 +7876,9 @@ export default {
"modelId": [
1
],
"fileIds": [
3,
"[UUID!]"
"fileAttachments": [
440,
"[FileAttachmentInput!]"
]
}
],
@@ -7944,7 +7944,7 @@ export default {
290,
{
"input": [
440,
441,
"CreateSkillInput!"
]
}
@@ -7953,7 +7953,7 @@ export default {
290,
{
"input": [
441,
442,
"UpdateSkillInput!"
]
}
@@ -8011,7 +8011,7 @@ export default {
238,
{
"input": [
442,
443,
"GetAuthorizationUrlForSSOInput!"
]
}
@@ -8265,7 +8265,7 @@ export default {
196,
{
"input": [
443,
444,
"CreateApplicationRegistrationInput!"
]
}
@@ -8274,7 +8274,7 @@ export default {
7,
{
"input": [
444,
445,
"UpdateApplicationRegistrationInput!"
]
}
@@ -8301,7 +8301,7 @@ export default {
5,
{
"input": [
446,
447,
"CreateApplicationRegistrationVariableInput!"
]
}
@@ -8310,7 +8310,7 @@ export default {
5,
{
"input": [
447,
448,
"UpdateApplicationRegistrationVariableInput!"
]
}
@@ -8399,7 +8399,7 @@ export default {
6,
{
"input": [
449,
450,
"UpdateWorkspaceMemberSettingsInput!"
]
}
@@ -8433,7 +8433,7 @@ export default {
75,
{
"data": [
450,
451,
"ActivateWorkspaceInput!"
]
}
@@ -8442,7 +8442,7 @@ export default {
75,
{
"data": [
451,
452,
"UpdateWorkspaceInput!"
]
}
@@ -8469,7 +8469,7 @@ export default {
6,
{
"workspaceMigration": [
452,
453,
"WorkspaceMigrationInput!"
]
}
@@ -8487,7 +8487,7 @@ export default {
220,
{
"input": [
455,
456,
"SetupOIDCSsoInput!"
]
}
@@ -8496,7 +8496,7 @@ export default {
220,
{
"input": [
456,
457,
"SetupSAMLSsoInput!"
]
}
@@ -8505,7 +8505,7 @@ export default {
216,
{
"input": [
457,
458,
"DeleteSsoInput!"
]
}
@@ -8514,7 +8514,7 @@ export default {
217,
{
"input": [
458,
459,
"EditSsoInput!"
]
}
@@ -8545,7 +8545,7 @@ export default {
286,
{
"input": [
459,
460,
"SendEmailInput!"
]
}
@@ -8571,7 +8571,7 @@ export default {
"String!"
],
"connectionParameters": [
461,
462,
"EmailAccountConnectionParameters!"
],
"id": [
@@ -8583,7 +8583,7 @@ export default {
168,
{
"input": [
463,
464,
"UpdateLabPublicFeatureFlagInput!"
]
}
@@ -8665,7 +8665,7 @@ export default {
77,
{
"input": [
464,
465,
"CreateOneAppTokenInput!"
]
}
@@ -8728,7 +8728,7 @@ export default {
"String!"
],
"fileFolder": [
466,
467,
"FileFolder!"
],
"filePath": [
@@ -10746,6 +10746,17 @@ export default {
1
]
},
"FileAttachmentInput": {
"id": [
3
],
"filename": [
1
],
"__typename": [
1
]
},
"CreateSkillInput": {
"id": [
3
@@ -10828,7 +10839,7 @@ export default {
1
],
"update": [
445
446
],
"__typename": [
1
@@ -10876,7 +10887,7 @@ export default {
1
],
"update": [
448
449
],
"__typename": [
1
@@ -10994,7 +11005,7 @@ export default {
},
"WorkspaceMigrationInput": {
"actions": [
453
454
],
"__typename": [
1
@@ -11002,7 +11013,7 @@ export default {
},
"WorkspaceMigrationDeleteActionInput": {
"type": [
454
455
],
"metadataName": [
318
@@ -11097,7 +11108,7 @@ export default {
1
],
"files": [
460
461
],
"__typename": [
1
@@ -11116,13 +11127,13 @@ export default {
},
"EmailAccountConnectionParameters": {
"IMAP": [
462
463
],
"SMTP": [
462
463
],
"CALDAV": [
462
463
],
"__typename": [
1
@@ -11161,7 +11172,7 @@ export default {
},
"CreateOneAppTokenInput": {
"appToken": [
465
466
],
"__typename": [
1
@@ -11190,7 +11201,7 @@ export default {
230,
{
"input": [
468,
469,
"LogicFunctionLogsInput!"
]
}
@@ -1792,6 +1792,11 @@ export type File = {
size: Scalars['Float'];
};
export type FileAttachmentInput = {
filename: Scalars['String'];
id: Scalars['UUID'];
};
export enum FileFolder {
AgentChat = 'AgentChat',
AppTarball = 'AppTarball',
@@ -3197,7 +3202,7 @@ export type MutationSaveImapSmtpCaldavAccountArgs = {
export type MutationSendChatMessageArgs = {
browsingContext?: InputMaybe<Scalars['JSON']>;
fileIds?: InputMaybe<Array<Scalars['UUID']>>;
fileAttachments?: InputMaybe<Array<FileAttachmentInput>>;
messageId: Scalars['UUID'];
modelId?: InputMaybe<Scalars['String']>;
text: Scalars['String'];
@@ -6148,7 +6153,7 @@ export type SendChatMessageMutationVariables = Exact<{
messageId: Scalars['UUID'];
browsingContext?: InputMaybe<Scalars['JSON']>;
modelId?: InputMaybe<Scalars['String']>;
fileIds?: InputMaybe<Array<Scalars['UUID']> | Scalars['UUID']>;
fileAttachments?: InputMaybe<Array<FileAttachmentInput> | FileAttachmentInput>;
}>;
@@ -7983,7 +7988,7 @@ export const EvaluateAgentTurnDocument = {"kind":"Document","definitions":[{"kin
export const RemoveRoleFromAgentDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RemoveRoleFromAgent"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"agentId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"removeRoleFromAgent"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"agentId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"agentId"}}}]}]}}]} as unknown as DocumentNode<RemoveRoleFromAgentMutation, RemoveRoleFromAgentMutationVariables>;
export const RenameChatThreadDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RenameChatThread"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"title"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"renameChatThread"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}},{"kind":"Argument","name":{"kind":"Name","value":"title"},"value":{"kind":"Variable","name":{"kind":"Name","value":"title"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode<RenameChatThreadMutation, RenameChatThreadMutationVariables>;
export const RunEvaluationInputDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RunEvaluationInput"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"agentId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"runEvaluationInput"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"agentId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"agentId"}}},{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"threadId"}},{"kind":"Field","name":{"kind":"Name","value":"agentId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"evaluations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"score"}},{"kind":"Field","name":{"kind":"Name","value":"comment"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}}]}}]} as unknown as DocumentNode<RunEvaluationInputMutation, RunEvaluationInputMutationVariables>;
export const SendChatMessageDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SendChatMessage"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"threadId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"text"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"messageId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"browsingContext"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"JSON"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"modelId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"fileIds"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"sendChatMessage"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"threadId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"threadId"}}},{"kind":"Argument","name":{"kind":"Name","value":"text"},"value":{"kind":"Variable","name":{"kind":"Name","value":"text"}}},{"kind":"Argument","name":{"kind":"Name","value":"messageId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"messageId"}}},{"kind":"Argument","name":{"kind":"Name","value":"browsingContext"},"value":{"kind":"Variable","name":{"kind":"Name","value":"browsingContext"}}},{"kind":"Argument","name":{"kind":"Name","value":"modelId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"modelId"}}},{"kind":"Argument","name":{"kind":"Name","value":"fileIds"},"value":{"kind":"Variable","name":{"kind":"Name","value":"fileIds"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"messageId"}},{"kind":"Field","name":{"kind":"Name","value":"queued"}},{"kind":"Field","name":{"kind":"Name","value":"streamId"}}]}}]}}]} as unknown as DocumentNode<SendChatMessageMutation, SendChatMessageMutationVariables>;
export const SendChatMessageDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SendChatMessage"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"threadId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"text"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"messageId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"browsingContext"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"JSON"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"modelId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"fileAttachments"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"FileAttachmentInput"}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"sendChatMessage"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"threadId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"threadId"}}},{"kind":"Argument","name":{"kind":"Name","value":"text"},"value":{"kind":"Variable","name":{"kind":"Name","value":"text"}}},{"kind":"Argument","name":{"kind":"Name","value":"messageId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"messageId"}}},{"kind":"Argument","name":{"kind":"Name","value":"browsingContext"},"value":{"kind":"Variable","name":{"kind":"Name","value":"browsingContext"}}},{"kind":"Argument","name":{"kind":"Name","value":"modelId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"modelId"}}},{"kind":"Argument","name":{"kind":"Name","value":"fileAttachments"},"value":{"kind":"Variable","name":{"kind":"Name","value":"fileAttachments"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"messageId"}},{"kind":"Field","name":{"kind":"Name","value":"queued"}},{"kind":"Field","name":{"kind":"Name","value":"streamId"}}]}}]}}]} as unknown as DocumentNode<SendChatMessageMutation, SendChatMessageMutationVariables>;
export const StopAgentChatStreamDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"StopAgentChatStream"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"threadId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"stopAgentChatStream"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"threadId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"threadId"}}}]}]}}]} as unknown as DocumentNode<StopAgentChatStreamMutation, StopAgentChatStreamMutationVariables>;
export const UnarchiveChatThreadDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UnarchiveChatThread"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"unarchiveChatThread"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"deletedAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode<UnarchiveChatThreadMutation, UnarchiveChatThreadMutationVariables>;
export const UpdateOneAgentDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateOneAgent"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateAgentInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateOneAgent"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AgentFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"AgentFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Agent"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}},{"kind":"Field","name":{"kind":"Name","value":"prompt"}},{"kind":"Field","name":{"kind":"Name","value":"modelId"}},{"kind":"Field","name":{"kind":"Name","value":"responseFormat"}},{"kind":"Field","name":{"kind":"Name","value":"roleId"}},{"kind":"Field","name":{"kind":"Name","value":"isCustom"}},{"kind":"Field","name":{"kind":"Name","value":"modelConfiguration"}},{"kind":"Field","name":{"kind":"Name","value":"evaluationInputs"}},{"kind":"Field","name":{"kind":"Name","value":"applicationId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode<UpdateOneAgentMutation, UpdateOneAgentMutationVariables>;
@@ -2652,11 +2652,6 @@ msgstr "Terug na {linkText}"
msgid "Back to content"
msgstr "Terug na inhoud"
#. js-lingui-id: OvBnOM
#: src/modules/ui/navigation/bread-crumb/components/MobileBreadcrumb.tsx
msgid "Back to Settings"
msgstr "Terug na Instellings"
#. js-lingui-id: GtJbUa
#: src/modules/error-handler/components/AppRootErrorFallback.tsx
msgid "Background"
@@ -2652,11 +2652,6 @@ msgstr "العودة إلى {linkText}"
msgid "Back to content"
msgstr "العودة إلى المحتوى"
#. js-lingui-id: OvBnOM
#: src/modules/ui/navigation/bread-crumb/components/MobileBreadcrumb.tsx
msgid "Back to Settings"
msgstr "العودة إلى الإعدادات"
#. js-lingui-id: GtJbUa
#: src/modules/error-handler/components/AppRootErrorFallback.tsx
msgid "Background"
@@ -2652,11 +2652,6 @@ msgstr "Torna a {linkText}"
msgid "Back to content"
msgstr "Tornar al contingut"
#. js-lingui-id: OvBnOM
#: src/modules/ui/navigation/bread-crumb/components/MobileBreadcrumb.tsx
msgid "Back to Settings"
msgstr "Torna a Configuració"
#. js-lingui-id: GtJbUa
#: src/modules/error-handler/components/AppRootErrorFallback.tsx
msgid "Background"
@@ -2652,11 +2652,6 @@ msgstr "Zpět na {linkText}"
msgid "Back to content"
msgstr "Zpět k obsahu"
#. js-lingui-id: OvBnOM
#: src/modules/ui/navigation/bread-crumb/components/MobileBreadcrumb.tsx
msgid "Back to Settings"
msgstr "Zpět do nastavení"
#. js-lingui-id: GtJbUa
#: src/modules/error-handler/components/AppRootErrorFallback.tsx
msgid "Background"
@@ -2652,11 +2652,6 @@ msgstr "Tilbage til {linkText}"
msgid "Back to content"
msgstr "Tilbage til indhold"
#. js-lingui-id: OvBnOM
#: src/modules/ui/navigation/bread-crumb/components/MobileBreadcrumb.tsx
msgid "Back to Settings"
msgstr "Tilbage til Indstillinger"
#. js-lingui-id: GtJbUa
#: src/modules/error-handler/components/AppRootErrorFallback.tsx
msgid "Background"
@@ -2652,11 +2652,6 @@ msgstr "Zurück zu {linkText}"
msgid "Back to content"
msgstr "Zurück zum Inhalt"
#. js-lingui-id: OvBnOM
#: src/modules/ui/navigation/bread-crumb/components/MobileBreadcrumb.tsx
msgid "Back to Settings"
msgstr "Zurück zu den Einstellungen"
#. js-lingui-id: GtJbUa
#: src/modules/error-handler/components/AppRootErrorFallback.tsx
msgid "Background"
@@ -2652,11 +2652,6 @@ msgstr "Πίσω στο {linkText}"
msgid "Back to content"
msgstr "Επιστροφή στο περιεχόμενο"
#. js-lingui-id: OvBnOM
#: src/modules/ui/navigation/bread-crumb/components/MobileBreadcrumb.tsx
msgid "Back to Settings"
msgstr "Πίσω στις Ρυθμίσεις"
#. js-lingui-id: GtJbUa
#: src/modules/error-handler/components/AppRootErrorFallback.tsx
msgid "Background"
-5
View File
@@ -2647,11 +2647,6 @@ msgstr "Back to {linkText}"
msgid "Back to content"
msgstr "Back to content"
#. js-lingui-id: OvBnOM
#: src/modules/ui/navigation/bread-crumb/components/MobileBreadcrumb.tsx
msgid "Back to Settings"
msgstr "Back to Settings"
#. js-lingui-id: GtJbUa
#: src/modules/error-handler/components/AppRootErrorFallback.tsx
msgid "Background"
@@ -2652,11 +2652,6 @@ msgstr "Volver a {linkText}"
msgid "Back to content"
msgstr "Volver al contenido"
#. js-lingui-id: OvBnOM
#: src/modules/ui/navigation/bread-crumb/components/MobileBreadcrumb.tsx
msgid "Back to Settings"
msgstr "Volver a Configuración"
#. js-lingui-id: GtJbUa
#: src/modules/error-handler/components/AppRootErrorFallback.tsx
msgid "Background"
@@ -2652,11 +2652,6 @@ msgstr "Takaisin kohteeseen {linkText}"
msgid "Back to content"
msgstr "Takaisin sisältöön"
#. js-lingui-id: OvBnOM
#: src/modules/ui/navigation/bread-crumb/components/MobileBreadcrumb.tsx
msgid "Back to Settings"
msgstr "Takaisin asetuksiin"
#. js-lingui-id: GtJbUa
#: src/modules/error-handler/components/AppRootErrorFallback.tsx
msgid "Background"
@@ -2652,11 +2652,6 @@ msgstr "Retour à {linkText}"
msgid "Back to content"
msgstr "Retour au contenu"
#. js-lingui-id: OvBnOM
#: src/modules/ui/navigation/bread-crumb/components/MobileBreadcrumb.tsx
msgid "Back to Settings"
msgstr "Retour aux paramètres"
#. js-lingui-id: GtJbUa
#: src/modules/error-handler/components/AppRootErrorFallback.tsx
msgid "Background"
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -2652,11 +2652,6 @@ msgstr "חזרה אל {linkText}"
msgid "Back to content"
msgstr "חזרה לתוכן"
#. js-lingui-id: OvBnOM
#: src/modules/ui/navigation/bread-crumb/components/MobileBreadcrumb.tsx
msgid "Back to Settings"
msgstr "חזרה להגדרות"
#. js-lingui-id: GtJbUa
#: src/modules/error-handler/components/AppRootErrorFallback.tsx
msgid "Background"
@@ -2652,11 +2652,6 @@ msgstr "Vissza ide: {linkText}"
msgid "Back to content"
msgstr "Vissza a tartalomhoz"
#. js-lingui-id: OvBnOM
#: src/modules/ui/navigation/bread-crumb/components/MobileBreadcrumb.tsx
msgid "Back to Settings"
msgstr "Vissza a beállításokhoz"
#. js-lingui-id: GtJbUa
#: src/modules/error-handler/components/AppRootErrorFallback.tsx
msgid "Background"
@@ -2652,11 +2652,6 @@ msgstr "Torna a {linkText}"
msgid "Back to content"
msgstr "Torna al contenuto"
#. js-lingui-id: OvBnOM
#: src/modules/ui/navigation/bread-crumb/components/MobileBreadcrumb.tsx
msgid "Back to Settings"
msgstr "Torna alle Impostazioni"
#. js-lingui-id: GtJbUa
#: src/modules/error-handler/components/AppRootErrorFallback.tsx
msgid "Background"
@@ -2652,11 +2652,6 @@ msgstr "{linkText} に戻る"
msgid "Back to content"
msgstr "コンテンツに戻る"
#. js-lingui-id: OvBnOM
#: src/modules/ui/navigation/bread-crumb/components/MobileBreadcrumb.tsx
msgid "Back to Settings"
msgstr "設定に戻る"
#. js-lingui-id: GtJbUa
#: src/modules/error-handler/components/AppRootErrorFallback.tsx
msgid "Background"
@@ -2652,11 +2652,6 @@ msgstr "{linkText}(으)로 돌아가기"
msgid "Back to content"
msgstr "콘텐츠로 돌아가기"
#. js-lingui-id: OvBnOM
#: src/modules/ui/navigation/bread-crumb/components/MobileBreadcrumb.tsx
msgid "Back to Settings"
msgstr "설정으로 돌아가기"
#. js-lingui-id: GtJbUa
#: src/modules/error-handler/components/AppRootErrorFallback.tsx
msgid "Background"
@@ -2652,11 +2652,6 @@ msgstr "Terug naar {linkText}"
msgid "Back to content"
msgstr "Terug naar inhoud"
#. js-lingui-id: OvBnOM
#: src/modules/ui/navigation/bread-crumb/components/MobileBreadcrumb.tsx
msgid "Back to Settings"
msgstr "Terug naar Instellingen"
#. js-lingui-id: GtJbUa
#: src/modules/error-handler/components/AppRootErrorFallback.tsx
msgid "Background"
@@ -2652,11 +2652,6 @@ msgstr "Tilbake til {linkText}"
msgid "Back to content"
msgstr "Tilbake til innhold"
#. js-lingui-id: OvBnOM
#: src/modules/ui/navigation/bread-crumb/components/MobileBreadcrumb.tsx
msgid "Back to Settings"
msgstr "Tilbake til Innstillinger"
#. js-lingui-id: GtJbUa
#: src/modules/error-handler/components/AppRootErrorFallback.tsx
msgid "Background"
@@ -2652,11 +2652,6 @@ msgstr "Powrót do {linkText}"
msgid "Back to content"
msgstr "Powrót do treści"
#. js-lingui-id: OvBnOM
#: src/modules/ui/navigation/bread-crumb/components/MobileBreadcrumb.tsx
msgid "Back to Settings"
msgstr "Powrót do ustawień"
#. js-lingui-id: GtJbUa
#: src/modules/error-handler/components/AppRootErrorFallback.tsx
msgid "Background"
@@ -2647,11 +2647,6 @@ msgstr ""
msgid "Back to content"
msgstr ""
#. js-lingui-id: OvBnOM
#: src/modules/ui/navigation/bread-crumb/components/MobileBreadcrumb.tsx
msgid "Back to Settings"
msgstr ""
#. js-lingui-id: GtJbUa
#: src/modules/error-handler/components/AppRootErrorFallback.tsx
msgid "Background"
@@ -2652,11 +2652,6 @@ msgstr "Voltar para {linkText}"
msgid "Back to content"
msgstr "Voltar ao conteúdo"
#. js-lingui-id: OvBnOM
#: src/modules/ui/navigation/bread-crumb/components/MobileBreadcrumb.tsx
msgid "Back to Settings"
msgstr "Voltar para Configurações"
#. js-lingui-id: GtJbUa
#: src/modules/error-handler/components/AppRootErrorFallback.tsx
msgid "Background"
@@ -2652,11 +2652,6 @@ msgstr "Voltar a {linkText}"
msgid "Back to content"
msgstr "Voltar ao conteúdo"
#. js-lingui-id: OvBnOM
#: src/modules/ui/navigation/bread-crumb/components/MobileBreadcrumb.tsx
msgid "Back to Settings"
msgstr "Voltar às Definições"
#. js-lingui-id: GtJbUa
#: src/modules/error-handler/components/AppRootErrorFallback.tsx
msgid "Background"
@@ -2652,11 +2652,6 @@ msgstr "Înapoi la {linkText}"
msgid "Back to content"
msgstr "Înapoi la conținut"
#. js-lingui-id: OvBnOM
#: src/modules/ui/navigation/bread-crumb/components/MobileBreadcrumb.tsx
msgid "Back to Settings"
msgstr "Înapoi la Setări"
#. js-lingui-id: GtJbUa
#: src/modules/error-handler/components/AppRootErrorFallback.tsx
msgid "Background"
Binary file not shown.
@@ -2652,11 +2652,6 @@ msgstr "Назад на {linkText}"
msgid "Back to content"
msgstr "Назад на садржај"
#. js-lingui-id: OvBnOM
#: src/modules/ui/navigation/bread-crumb/components/MobileBreadcrumb.tsx
msgid "Back to Settings"
msgstr "Назад на Подешавања"
#. js-lingui-id: GtJbUa
#: src/modules/error-handler/components/AppRootErrorFallback.tsx
msgid "Background"
@@ -2652,11 +2652,6 @@ msgstr "Tillbaka till {linkText}"
msgid "Back to content"
msgstr "Tillbaka till innehåll"
#. js-lingui-id: OvBnOM
#: src/modules/ui/navigation/bread-crumb/components/MobileBreadcrumb.tsx
msgid "Back to Settings"
msgstr "Tillbaka till inställningar"
#. js-lingui-id: GtJbUa
#: src/modules/error-handler/components/AppRootErrorFallback.tsx
msgid "Background"
@@ -2652,11 +2652,6 @@ msgstr "{linkText}'e geri dön"
msgid "Back to content"
msgstr "İçeriğe geri dön"
#. js-lingui-id: OvBnOM
#: src/modules/ui/navigation/bread-crumb/components/MobileBreadcrumb.tsx
msgid "Back to Settings"
msgstr "Ayarlar'a geri dön"
#. js-lingui-id: GtJbUa
#: src/modules/error-handler/components/AppRootErrorFallback.tsx
msgid "Background"
@@ -2652,11 +2652,6 @@ msgstr "Назад до {linkText}"
msgid "Back to content"
msgstr "Повернутися до вмісту"
#. js-lingui-id: OvBnOM
#: src/modules/ui/navigation/bread-crumb/components/MobileBreadcrumb.tsx
msgid "Back to Settings"
msgstr "Назад до налаштувань"
#. js-lingui-id: GtJbUa
#: src/modules/error-handler/components/AppRootErrorFallback.tsx
msgid "Background"
@@ -2652,11 +2652,6 @@ msgstr "Quay lại {linkText}"
msgid "Back to content"
msgstr "Quay lại nội dung"
#. js-lingui-id: OvBnOM
#: src/modules/ui/navigation/bread-crumb/components/MobileBreadcrumb.tsx
msgid "Back to Settings"
msgstr "Quay lại Cài đặt"
#. js-lingui-id: GtJbUa
#: src/modules/error-handler/components/AppRootErrorFallback.tsx
msgid "Background"
@@ -2652,11 +2652,6 @@ msgstr "返回 {linkText}"
msgid "Back to content"
msgstr "返回内容"
#. js-lingui-id: OvBnOM
#: src/modules/ui/navigation/bread-crumb/components/MobileBreadcrumb.tsx
msgid "Back to Settings"
msgstr "返回设置"
#. js-lingui-id: GtJbUa
#: src/modules/error-handler/components/AppRootErrorFallback.tsx
msgid "Background"
@@ -2652,11 +2652,6 @@ msgstr "返回 {linkText}"
msgid "Back to content"
msgstr "返回內容"
#. js-lingui-id: OvBnOM
#: src/modules/ui/navigation/bread-crumb/components/MobileBreadcrumb.tsx
msgid "Back to Settings"
msgstr "返回設定"
#. js-lingui-id: GtJbUa
#: src/modules/error-handler/components/AppRootErrorFallback.tsx
msgid "Background"
@@ -7,7 +7,7 @@ export const SEND_CHAT_MESSAGE = gql`
$messageId: UUID!
$browsingContext: JSON
$modelId: String
$fileIds: [UUID!]
$fileAttachments: [FileAttachmentInput!]
) {
sendChatMessage(
threadId: $threadId
@@ -15,7 +15,7 @@ export const SEND_CHAT_MESSAGE = gql`
messageId: $messageId
browsingContext: $browsingContext
modelId: $modelId
fileIds: $fileIds
fileAttachments: $fileAttachments
) {
messageId
queued
@@ -30,7 +30,6 @@ import { useListenToBrowserEvent } from '@/browser-event/hooks/useListenToBrowse
import { dispatchBrowserEvent } from '@/browser-event/utils/dispatchBrowserEvent';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
export const useAgentChat = (
@@ -44,11 +43,9 @@ export const useAgentChat = (
const setCurrentAiChatThread = useSetAtomState(currentAiChatThreadState);
const store = useStore();
const agentChatSelectedFiles = useAtomStateValue(agentChatSelectedFilesState);
const [, setPendingThreadIdAfterFirstSend] = useState<string | null>(null);
const [agentChatUploadedFiles, setAgentChatUploadedFiles] = useAtomState(
const setAgentChatUploadedFiles = useSetAtomState(
agentChatUploadedFilesState,
);
@@ -74,12 +71,14 @@ export const useAgentChat = (
return;
}
const isLoading = agentChatSelectedFiles.length > 0;
const agentChatSelectedFiles = store.get(agentChatSelectedFilesState.atom);
if (isLoading) {
if (agentChatSelectedFiles.length > 0) {
return;
}
const agentChatUploadedFiles = store.get(agentChatUploadedFilesState.atom);
const threadId = await ensureThreadIdForSend();
if (!isDefined(threadId)) {
@@ -131,7 +130,11 @@ export const useAgentChat = (
store.set(messagesAtom, [...currentMessages, optimisticUserMessage]);
store.set(errorAtom, null);
const fileIds = agentChatUploadedFiles.map((file) => file.fileId);
const fileAttachments = agentChatUploadedFiles.map((file) => ({
id: file.fileId,
filename: file.filename,
}));
const uploadedFilesSnapshot = agentChatUploadedFiles;
setAgentChatUploadedFiles([]);
@@ -150,7 +153,8 @@ export const useAgentChat = (
messageId,
browsingContext: browsingContext ?? null,
modelId: modelIdForRequest ?? undefined,
fileIds: fileIds.length > 0 ? fileIds : undefined,
fileAttachments:
fileAttachments.length > 0 ? fileAttachments : undefined,
},
});
@@ -186,6 +190,7 @@ export const useAgentChat = (
? { [AGENT_CHAT_NEW_THREAD_DRAFT_KEY]: '' }
: {}),
}));
setAgentChatUploadedFiles(uploadedFilesSnapshot);
const latestMessages = store.get(messagesAtom);
@@ -214,11 +219,9 @@ export const useAgentChat = (
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
store,
agentChatSelectedFiles,
ensureThreadIdForSend,
setAgentChatInput,
getBrowsingContext,
agentChatUploadedFiles,
setAgentChatUploadedFiles,
setAgentChatDraftsByThreadId,
modelIdForRequest,
@@ -120,7 +120,7 @@ const StyledNewChatButton = styled.div`
justify-content: center;
min-width: 0;
overflow: hidden;
padding-inline: ${themeCssVariables.spacing[1]};
padding-inline: ${themeCssVariables.spacing[2]};
transition:
background calc(${themeCssVariables.animation.duration.fast} * 1s) ease,
color calc(${themeCssVariables.animation.duration.fast} * 1s) ease;
@@ -10,6 +10,7 @@ import { useOpenRecordsSearchPageInSidePanel } from '@/side-panel/hooks/useOpenR
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
import { isSidePanelOpenedState } from '@/side-panel/states/isSidePanelOpenedState';
import { isNavigationDrawerExpandedState } from '@/ui/navigation/states/isNavigationDrawerExpanded';
import { navigationMemorizedUrlState } from '@/ui/navigation/states/navigationMemorizedUrlState';
import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
@@ -29,6 +30,7 @@ export const MobileNavigationBar = () => {
const navigate = useNavigate();
const { defaultHomePagePath } = useDefaultHomePagePath();
const isSidePanelOpened = useAtomStateValue(isSidePanelOpenedState);
const navigationMemorizedUrl = useAtomStateValue(navigationMemorizedUrlState);
const { closeSidePanelMenu } = useSidePanelMenu();
const { openRecordsSearchPage } = useOpenRecordsSearchPageInSidePanel();
const isSettingsPage = useIsSettingsPage();
@@ -70,7 +72,11 @@ export const MobileNavigationBar = () => {
setCurrentMobileNavigationDrawer('main');
if (isSettingsPage) {
navigate(defaultHomePagePath);
navigate(
navigationMemorizedUrl !== '/'
? navigationMemorizedUrl
: defaultHomePagePath,
);
}
},
},
@@ -9,7 +9,7 @@ export const useIsSettingsDrawer = () => {
const currentMobileNavigationDrawer = useAtomStateValue(
currentMobileNavigationDrawerState,
);
return isMobile
? currentMobileNavigationDrawer === 'settings'
: isSettingsPage;
return (
isSettingsPage || (isMobile && currentMobileNavigationDrawer === 'settings')
);
};
@@ -1,11 +1,15 @@
import { useIsSettingsDrawer } from '@/navigation/hooks/useIsSettingsDrawer';
import { isNavigationDrawerExpandedState } from '@/ui/navigation/states/isNavigationDrawerExpanded';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
export const useNavigationDrawerExpanded = () => {
const isMobile = useIsMobile();
const isSettingsDrawer = useIsSettingsDrawer();
const isNavigationDrawerExpanded = useAtomStateValue(
isNavigationDrawerExpandedState,
);
return isSettingsDrawer || isNavigationDrawerExpanded;
return isMobile
? isNavigationDrawerExpanded
: isSettingsDrawer || isNavigationDrawerExpanded;
};
@@ -1,10 +1,12 @@
import { metadataStoreState } from '@/metadata-store/states/metadataStoreState';
import { type FlatObjectMetadataItem } from '@/metadata-store/types/FlatObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { PageLayoutLeftPanel } from '@/page-layout/components/PageLayoutLeftPanel';
import { PageLayoutTabList } from '@/page-layout/components/PageLayoutTabList';
import { PageLayoutTabListEffect } from '@/page-layout/components/PageLayoutTabListEffect';
import { DEFAULT_RECORD_PAGE_LAYOUT_ID } from '@/page-layout/constants/DefaultRecordPageLayoutId';
import { PAGE_LAYOUT_LEFT_PANEL_CONTAINER_WIDTH } from '@/page-layout/constants/PageLayoutLeftPanelContainerWidth';
import { WIDGET_TYPE_TO_RELATION_FIELD_NAME } from '@/page-layout/constants/WidgetTypeToRelationFieldName';
import { useCurrentPageLayoutOrThrow } from '@/page-layout/hooks/useCurrentPageLayoutOrThrow';
import { useIsPageLayoutInEditMode } from '@/page-layout/hooks/useIsPageLayoutInEditMode';
import { usePageLayoutAddTabStrategy } from '@/page-layout/hooks/usePageLayoutAddTabStrategy';
@@ -22,8 +24,11 @@ import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
import { styled } from '@linaria/react';
import { useMemo } from 'react';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { useIsMobile } from 'twenty-ui/utilities';
const StyledContainer = styled.div<{ hasPinnedTab: boolean }>`
display: grid;
grid-template-columns: ${({ hasPinnedTab }) =>
@@ -69,6 +74,34 @@ export const PageLayoutTabsRenderer = () => {
currentPageLayout.id,
);
const { objectMetadataItems } = useObjectMetadataItems();
const inactiveRelationFieldNames = useMemo(() => {
if (!isDefined(targetRecordIdentifier)) {
return new Set<string>();
}
const objectMetadataItem = objectMetadataItems.find(
(item) =>
item.nameSingular === targetRecordIdentifier.targetObjectNameSingular,
);
if (!isDefined(objectMetadataItem)) {
return new Set<string>();
}
return new Set(
objectMetadataItem.fields
.filter(
(field) =>
!field.isActive &&
(field.type === FieldMetadataType.RELATION ||
field.type === FieldMetadataType.MORPH_RELATION),
)
.map((field) => field.name),
);
}, [objectMetadataItems, targetRecordIdentifier]);
const isMobile = useIsMobile();
const metadataStore = useAtomFamilyStateValue(
@@ -114,6 +147,22 @@ export const PageLayoutTabsRenderer = () => {
const sortedTabs = sortTabsByPosition(tabsToRenderInTabList);
const sortedActiveTabs = useMemo(
() =>
sortedTabs.filter((tab) => {
const widgetTypes = tab.widgets.map((widget) => widget.type);
return !widgetTypes.some((widgetType) => {
const relationFieldName =
WIDGET_TYPE_TO_RELATION_FIELD_NAME[widgetType];
return (
isDefined(relationFieldName) &&
inactiveRelationFieldNames.has(relationFieldName)
);
});
}),
[sortedTabs, inactiveRelationFieldNames],
);
const activeTabExistsInCurrentPageLayout = currentPageLayout.tabs.some(
(tab) => tab.id === activeTabId,
);
@@ -126,16 +175,16 @@ export const PageLayoutTabsRenderer = () => {
<StyledTabsAndDashboardContainer>
<PageLayoutTabListEffect
tabs={sortedTabs}
tabs={sortedActiveTabs}
componentInstanceId={tabListInstanceId}
defaultTabToFocusOnMobileAndSidePanelId={
currentPageLayout.defaultTabToFocusOnMobileAndSidePanelId ??
undefined
}
/>
{(sortedTabs.length > 1 || isPageLayoutInEditMode) && (
{(sortedActiveTabs.length > 1 || isPageLayoutInEditMode) && (
<PageLayoutTabList
tabs={sortedTabs}
tabs={sortedActiveTabs}
behaveAsLinks={!isInSidePanel && !isPageLayoutInEditMode}
isInSidePanel={isInSidePanel}
componentInstanceId={tabListInstanceId}
@@ -0,0 +1,12 @@
import { WidgetType } from '~/generated-metadata/graphql';
export const WIDGET_TYPE_TO_RELATION_FIELD_NAME: Partial<
Record<WidgetType, string>
> = {
[WidgetType.TASKS]: 'taskTargets',
[WidgetType.NOTES]: 'noteTargets',
[WidgetType.FILES]: 'attachments',
[WidgetType.TIMELINE]: 'timelineActivities',
[WidgetType.EMAILS]: 'messageParticipants',
[WidgetType.CALENDAR]: 'calendarEventParticipants',
};
@@ -48,6 +48,7 @@ const StyledPageContainerBase = styled.div`
flex: 1 1 auto;
flex-direction: row;
min-height: 0;
min-width: 0;
`;
const StyledPageContainer = motion.create(StyledPageContainerBase);
@@ -58,6 +59,7 @@ const StyledNavigationDrawerWrapper = styled.div`
const StyledMainContainer = styled.div`
display: flex;
flex: 0 1 100%;
min-width: 0;
overflow: hidden;
`;
@@ -2,7 +2,7 @@ import { styled } from '@linaria/react';
import { type ReactNode } from 'react';
import { PagePanel } from './PagePanel';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { MOBILE_VIEWPORT, themeCssVariables } from 'twenty-ui/theme-constants';
type PageBodyProps = {
children: ReactNode;
@@ -21,6 +21,10 @@ const StyledMainContainer = styled.div`
padding-left: 0;
padding-right: ${themeCssVariables.spacing[3]};
width: 100%;
@media (max-width: ${MOBILE_VIEWPORT}px) {
padding-left: ${themeCssVariables.spacing[3]};
}
`;
type LeftContainerProps = {
@@ -3,6 +3,7 @@ import { type ReactNode, useContext } from 'react';
import { NavigationDrawerCollapseButton } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerCollapseButton';
import { useIsSettingsPage } from '@/navigation/hooks/useIsSettingsPage';
import { useNavigationDrawerExpanded } from '@/navigation/hooks/useNavigationDrawerExpanded';
import { PAGE_ACTION_CONTAINER_CLICK_OUTSIDE_ID } from '@/ui/layout/page/constants/PageActionContainerClickOutsideId';
import { PAGE_BAR_MIN_HEIGHT } from '@/ui/layout/page/constants/PageBarMinHeight';
@@ -104,6 +105,7 @@ export const PageHeader = ({
className,
}: PageHeaderProps) => {
const isMobile = useIsMobile();
const isSettingsPage = useIsSettingsPage();
const { theme } = useContext(ThemeContext);
const isNavigationDrawerExpanded = useNavigationDrawerExpanded();
@@ -111,7 +113,7 @@ export const PageHeader = ({
<AnimatePresence initial={false}>
<StyledTopBarContainer className={className} isMobile={isMobile}>
<StyledLeftContainer>
{!isMobile && !isNavigationDrawerExpanded && (
{!isNavigationDrawerExpanded && (!isMobile || isSettingsPage) && (
<NavigationDrawerCollapseButton direction="right" />
)}
{hasClosePageButton && (
@@ -1,5 +1,5 @@
import { t } from '@lingui/core/macro';
import { useOpenSettingsMenu } from '@/navigation/hooks/useOpenSettings';
import { useIsSettingsPage } from '@/navigation/hooks/useIsSettingsPage';
import { styled } from '@linaria/react';
import { isNonEmptyString } from '@sniptt/guards';
import { type ReactNode, useContext } from 'react';
@@ -47,14 +47,13 @@ export const MobileBreadcrumb = ({
links,
}: MobileBreadcrumbProps) => {
const { theme } = useContext(ThemeContext);
const { openSettingsMenu } = useOpenSettingsMenu();
const isSettingsPage = useIsSettingsPage();
const handleBackToSettingsClick = () => {
openSettingsMenu();
};
if (isSettingsPage && links.length <= 2) {
return null;
}
const previousLink = links[links.length - 2];
const shouldRedirectToSettings = links.length === 2;
const text = isNonEmptyString(previousLink.children)
? previousLink.children
@@ -64,14 +63,7 @@ export const MobileBreadcrumb = ({
return (
<StyledWrapper className={className}>
{shouldRedirectToSettings ? (
<>
<IconChevronLeft size={theme.icon.size.md} />
<StyledText onClick={handleBackToSettingsClick}>
{t`Back to Settings`}
</StyledText>
</>
) : previousLink?.href ? (
{previousLink?.href ? (
<>
<IconChevronLeft size={theme.icon.size.md} />
<StyledLinkContainer>
@@ -47,13 +47,12 @@ const StyledAnimatedContainer = styled.div<{
: `${NAVIGATION_DRAWER_COLLAPSED_WIDTH}px`};
@media (max-width: ${MOBILE_VIEWPORT}px) {
width: ${({ isExpanded }) => (isExpanded ? '100%' : '0')};
width: ${({ isExpanded }) => (isExpanded ? '100vw' : '0')};
}
`;
const StyledContainer = styled.div<{
isSettings?: boolean;
isMobile?: boolean;
isExpanded?: boolean;
}>`
box-sizing: border-box;
@@ -61,18 +60,17 @@ const StyledContainer = styled.div<{
flex-direction: column;
gap: ${themeCssVariables.spacing[3]};
height: 100%;
padding: ${({ isSettings, isMobile }) =>
padding: ${({ isSettings }) =>
isSettings
? isMobile
? `${themeCssVariables.spacing[3]} 0 0 ${themeCssVariables.spacing[8]}`
: `${themeCssVariables.spacing[3]} 0 ${themeCssVariables.spacing[4]} 0`
? `${themeCssVariables.spacing[3]} 0 ${themeCssVariables.spacing[4]} 0`
: `${themeCssVariables.spacing[3]} 0 ${themeCssVariables.spacing[4]} ${themeCssVariables.spacing[2]}`};
width: ${({ isExpanded }) =>
isExpanded ? `var(${NAVIGATION_DRAWER_WIDTH_VAR})` : '100%'};
@media (max-width: ${MOBILE_VIEWPORT}px) {
gap: ${themeCssVariables.spacing[4]};
width: 100%;
padding-left: ${themeCssVariables.spacing[5]};
padding-right: ${themeCssVariables.spacing[5]};
padding-left: ${themeCssVariables.spacing[2]};
padding-right: ${themeCssVariables.spacing[2]};
}
`;
@@ -125,12 +123,8 @@ export const NavigationDrawer = ({
isExpanded={isExpanded}
isResizing={isResizing}
>
<StyledContainer
isSettings={isSettingsDrawer}
isMobile={isMobile}
isExpanded={isExpanded}
>
{isSettingsDrawer && title ? (
<StyledContainer isSettings={isSettingsDrawer} isExpanded={isExpanded}>
{!isMobile && isSettingsDrawer && title ? (
<NavigationDrawerBackButton title={title} />
) : (
<NavigationDrawerHeader showCollapseButton />
@@ -10,14 +10,14 @@ const StyledFixedContainer = styled.div<{
isSettings?: boolean;
isMobile?: boolean;
}>`
padding-left: ${({ isSettings }) =>
isSettings ? themeCssVariables.spacing[5] : '0'};
padding-left: ${({ isSettings, isMobile }) =>
isSettings || isMobile ? themeCssVariables.spacing[5] : '0'};
padding-right: ${({ isSettings, isMobile }) =>
isSettings
? isMobile
? themeCssVariables.spacing[5]
: themeCssVariables.spacing[8]
: '0'};
isMobile
? themeCssVariables.spacing[5]
: isSettings
? themeCssVariables.spacing[8]
: '0'};
`;
export const NavigationDrawerFixedContent = ({
children,
@@ -2,7 +2,7 @@ import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { IconSearch } from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { MOBILE_VIEWPORT, themeCssVariables } from 'twenty-ui/theme-constants';
import { useOpenRecordsSearchPageInSidePanel } from '@/side-panel/hooks/useOpenRecordsSearchPageInSidePanel';
import { PAGE_BAR_MIN_HEIGHT } from '@/ui/layout/page/constants/PageBarMinHeight';
@@ -21,6 +21,11 @@ const StyledContainer = styled.div<{ isExpanded: boolean }>`
padding-right: ${themeCssVariables.spacing[2]};
transition: gap calc(${themeCssVariables.animation.duration.normal} * 1s) ease;
user-select: none;
@media (max-width: ${MOBILE_VIEWPORT}px) {
padding-left: ${themeCssVariables.spacing[5]};
padding-right: ${themeCssVariables.spacing[5]};
}
`;
const StyledRightActions = styled.div<{ isExpanded: boolean }>`
@@ -40,6 +45,14 @@ const StyledNavigationDrawerCollapseButtonContainer = styled.div`
padding-right: ${themeCssVariables.spacing[1]};
width: ${themeCssVariables.spacing[6]};
}
@media (max-width: ${MOBILE_VIEWPORT}px) {
> * {
height: ${themeCssVariables.spacing[8]};
padding-right: 0;
width: ${themeCssVariables.spacing[8]};
}
}
`;
const StyledWorkspaceDropdownContainer = styled.div`
@@ -68,8 +81,8 @@ export const NavigationDrawerHeader = ({
<StyledWorkspaceDropdownContainer>
<MultiWorkspaceDropdownButton />
</StyledWorkspaceDropdownContainer>
{!isMobile && (
<StyledRightActions isExpanded={isNavigationDrawerExpanded}>
<StyledRightActions isExpanded={isNavigationDrawerExpanded}>
{!isMobile && (
<LightIconButton
Icon={IconSearch}
accent="secondary"
@@ -77,13 +90,13 @@ export const NavigationDrawerHeader = ({
onClick={openRecordsSearchPage}
aria-label={t`Search`}
/>
{isNavigationDrawerExpanded && showCollapseButton && (
<StyledNavigationDrawerCollapseButtonContainer>
<NavigationDrawerCollapseButton direction="left" />
</StyledNavigationDrawerCollapseButtonContainer>
)}
</StyledRightActions>
)}
)}
{isNavigationDrawerExpanded && showCollapseButton && (
<StyledNavigationDrawerCollapseButtonContainer>
<NavigationDrawerCollapseButton direction="left" />
</StyledNavigationDrawerCollapseButtonContainer>
)}
</StyledRightActions>
</StyledContainer>
);
};
@@ -24,6 +24,7 @@ import {
TooltipDelay,
TooltipPosition,
} from 'twenty-ui/display';
import { MenuItemIconBoxContainer } from 'twenty-ui/navigation';
import {
MOBILE_VIEWPORT,
ThemeContext,
@@ -140,7 +141,7 @@ const StyledItem = styled.button<StyledItemProps>`
}
@media (max-width: ${MOBILE_VIEWPORT}px) {
font-size: ${themeCssVariables.font.size.lg};
height: ${themeCssVariables.spacing[8]};
}
`;
@@ -161,7 +162,7 @@ const StyledLabelParent = styled.div`
`;
const StyledItemLabel = styled.span`
font-weight: ${themeCssVariables.font.weight.medium};
font-weight: ${themeCssVariables.font.weight.regular};
`;
const StyledItemSecondaryLabel = styled.span`
@@ -193,27 +194,13 @@ const StyledSpacer = styled.span`
flex-grow: 1;
`;
const StyledIcon = styled.div<{
$backgroundColor?: string;
$borderColor?: string;
}>`
const StyledIcon = styled.div`
align-items: center;
background-color: ${({ $backgroundColor }) =>
$backgroundColor || 'transparent'};
border: ${({ $backgroundColor, $borderColor }) =>
$backgroundColor && $borderColor ? `1px solid ${$borderColor}` : 'none'};
border-radius: ${({ $backgroundColor }) => ($backgroundColor ? '4px' : '0')};
box-sizing: ${({ $backgroundColor }) =>
$backgroundColor ? 'border-box' : 'content-box'};
display: flex;
flex-grow: 0;
flex-shrink: 0;
height: ${({ $backgroundColor }) =>
$backgroundColor ? themeCssVariables.spacing[4] : 'auto'};
justify-content: center;
margin-right: ${themeCssVariables.spacing[2]};
width: ${({ $backgroundColor }) =>
$backgroundColor ? themeCssVariables.spacing[4] : 'auto'};
`;
const StyledRightOptionsContainer = styled.div`
@@ -362,18 +349,20 @@ export const NavigationDrawerItem = ({
</StyledIcon>
) : (
<StyledIcon>
<Icon
style={{
minWidth: theme.icon.size.md,
}}
size={theme.icon.size.md}
stroke={theme.icon.stroke.md}
color={
showBreadcrumb && !isExpanded
? theme.font.color.light
: 'currentColor'
}
/>
<MenuItemIconBoxContainer>
<Icon
style={{
minWidth: theme.icon.size.md,
}}
size={theme.icon.size.md}
stroke={theme.icon.stroke.md}
color={
showBreadcrumb && !isExpanded
? theme.font.color.light
: 'currentColor'
}
/>
</MenuItemIconBoxContainer>
</StyledIcon>
))}
@@ -1,6 +1,6 @@
import { type NavigationDrawerSubItemState } from '@/ui/navigation/navigation-drawer/types/NavigationDrawerSubItemState';
import { styled } from '@linaria/react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { MOBILE_VIEWPORT, themeCssVariables } from 'twenty-ui/theme-constants';
export type NavigationDrawerItemBreadcrumbProps = {
state?: NavigationDrawerSubItemState;
@@ -12,6 +12,10 @@ const StyledNavigationDrawerItemBreadcrumbContainer = styled.div`
margin-left: 7.5px;
margin-right: ${themeCssVariables.spacing[2]};
width: 9px;
@media (max-width: ${MOBILE_VIEWPORT}px) {
height: ${themeCssVariables.spacing[8]};
}
`;
const StyledGapVerticalLine = styled.div<{ darker: boolean }>`
@@ -37,6 +41,10 @@ const StyledSecondaryFullVerticalBar = styled.div<{ darker: boolean }>`
position: relative;
top: -17px;
width: 1px;
@media (max-width: ${MOBILE_VIEWPORT}px) {
height: ${themeCssVariables.spacing[8]};
}
`;
const StyledRoundedProtrusion = styled.div<{ darker: boolean }>`
@@ -34,7 +34,7 @@ export const NavigationDrawerScrollableContent = ({
defaultEnableXScroll={false}
>
<StyledItemsContainer>
{isSettingsDrawer ? (
{isSettingsDrawer || isMobile ? (
<StyledScrollableInnerContainer isMobile={isMobile}>
{children}
</StyledScrollableInnerContainer>
@@ -17,8 +17,8 @@ const StyledTitle = styled.div`
justify-content: space-between;
padding-bottom: ${themeCssVariables.spacing[1]};
padding-left: ${themeCssVariables.spacing[1]};
padding-right: ${themeCssVariables.spacing['0.5']};
padding-top: ${themeCssVariables.spacing[1]};
padding-right: ${themeCssVariables.spacing[1]};
padding-top: ${themeCssVariables.spacing[2]};
&:hover {
background-color: ${themeCssVariables.background.transparent.light};
@@ -8,14 +8,18 @@ export class RenamePermissionFlagToRolePermissionFlagFastInstanceCommand
implements FastInstanceCommand
{
public async up(queryRunner: QueryRunner): Promise<void> {
// The legacy permissionFlag table stores per-role grants (`roleId` + `flag`).
// Rename it to preserve those rows before creating the new permissionFlag catalog table.
await queryRunner.query(
`ALTER TABLE "core"."permissionFlag" RENAME TO "rolePermissionFlag"`,
);
await queryRunner.query(
`ALTER TABLE "core"."rolePermissionFlag" RENAME CONSTRAINT "PK_a02789db60620a1e9f90147b50f" TO "PK_76591adc8035c2e7b0cd6115136"`,
`ALTER TABLE "core"."rolePermissionFlag" DROP CONSTRAINT IF EXISTS "PK_a02789db60620a1e9f90147b50f"`,
);
await queryRunner.query(
`ALTER TABLE "core"."rolePermissionFlag" DROP CONSTRAINT IF EXISTS "PK_8c144a021030d7e3326835a04c8"`,
);
await queryRunner.query(
`ALTER TABLE "core"."rolePermissionFlag" ADD CONSTRAINT "PK_76591adc8035c2e7b0cd6115136" PRIMARY KEY ("id")`,
);
await queryRunner.query(
@@ -26,9 +30,6 @@ export class RenamePermissionFlagToRolePermissionFlagFastInstanceCommand
`ALTER INDEX "core"."IDX_PERMISSION_FLAG_ROLE_ID" RENAME TO "IDX_ROLE_PERMISSION_FLAG_ROLE_ID"`,
);
// Re-hash inherited constraints/indexes so TypeORM's schema diff matches
// the renamed table. Original names were derived from "permissionFlag"
// and stay free for the new catalog table created by the next migration.
await queryRunner.query(
`ALTER TABLE "core"."rolePermissionFlag" DROP CONSTRAINT "FK_13f8ca9c517976733a1ce4c10eb"`,
);
@@ -92,7 +93,10 @@ export class RenamePermissionFlagToRolePermissionFlagFastInstanceCommand
);
await queryRunner.query(
`ALTER TABLE "core"."rolePermissionFlag" RENAME CONSTRAINT "PK_76591adc8035c2e7b0cd6115136" TO "PK_a02789db60620a1e9f90147b50f"`,
`ALTER TABLE "core"."rolePermissionFlag" DROP CONSTRAINT IF EXISTS "PK_76591adc8035c2e7b0cd6115136"`,
);
await queryRunner.query(
`ALTER TABLE "core"."rolePermissionFlag" ADD CONSTRAINT "PK_a02789db60620a1e9f90147b50f" PRIMARY KEY ("id")`,
);
await queryRunner.query(
@@ -1,12 +1,28 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { DataSource, type DataSourceOptions } from 'typeorm';
import { typeORMCoreModuleOptions } from 'src/database/typeorm/core/core.datasource';
import { DatabaseGaugeService } from 'src/database/typeorm/database-gauge.service';
import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module';
import { installUpgradeAwareRepositoryProxy } from 'src/engine/twenty-orm/upgrade-aware/install-upgrade-aware-repository-proxy';
@Module({
imports: [TypeOrmModule.forRoot(typeORMCoreModuleOptions), MetricsModule],
imports: [
TypeOrmModule.forRootAsync({
useFactory: () => typeORMCoreModuleOptions,
dataSourceFactory: async (options) => {
const dataSource = new DataSource(options as DataSourceOptions);
await dataSource.initialize();
installUpgradeAwareRepositoryProxy(dataSource);
return dataSource;
},
}),
MetricsModule,
],
providers: [DatabaseGaugeService],
exports: [],
})
@@ -1,9 +1,12 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { FileFolder } from 'twenty-shared/types';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { FileStorageDriverFactory } from 'src/engine/core-modules/file-storage/file-storage-driver.factory';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { FileStorageExceptionCode } from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
describe('FileStorageService', () => {
@@ -16,6 +19,9 @@ describe('FileStorageService', () => {
const mockFileRepository = {
save: jest.fn(),
upsert: jest.fn(),
findOneOrFail: jest.fn(),
delete: jest.fn(),
};
const mockApplicationRepository = {
@@ -63,6 +69,7 @@ describe('FileStorageService', () => {
delete: jest.fn(),
move: jest.fn(),
copy: jest.fn(),
downloadFile: jest.fn(),
downloadFolder: jest.fn(),
uploadFolder: jest.fn(),
checkFileExists: jest.fn(),
@@ -147,4 +154,306 @@ describe('FileStorageService', () => {
});
});
});
describe('path traversal protection', () => {
let mockDriver: any;
beforeEach(() => {
mockDriver = {
writeFile: jest.fn().mockResolvedValue(undefined),
readFile: jest.fn().mockResolvedValue('stream'),
delete: jest.fn().mockResolvedValue(undefined),
copy: jest.fn().mockResolvedValue(undefined),
downloadFile: jest.fn().mockResolvedValue(undefined),
checkFileExists: jest.fn().mockResolvedValue(true),
checkFolderExists: jest.fn().mockResolvedValue(true),
getPresignedUrl: jest.fn().mockResolvedValue('https://signed.url'),
};
mockFileStorageDriverFactory.getCurrentDriver.mockReturnValue(mockDriver);
mockApplicationRepository.findOneOrFail.mockResolvedValue({
id: 'app-id',
universalIdentifier: 'app-uid',
});
mockFileRepository.upsert.mockResolvedValue(undefined);
mockFileRepository.findOneOrFail.mockResolvedValue({
id: 'file-id',
path: 'BuiltFrontComponent/file.mjs',
mimeType: 'application/javascript',
});
});
const validResourceIdentifier = {
workspaceId: 'workspace-123',
applicationUniversalIdentifier: 'app-456',
fileFolder: FileFolder.BuiltFrontComponent,
resourcePath: 'src/components/my-component.mjs',
};
const expectedValidPath =
'workspace-123/app-456/built-front-component/src/components/my-component.mjs';
describe('readFile', () => {
it('should allow valid relative paths', async () => {
await service.readFile(validResourceIdentifier);
expect(mockDriver.readFile).toHaveBeenCalledWith({
filePath: expectedValidPath,
});
});
it('should reject path traversal with ../', () => {
expect(() =>
service.readFile({
...validResourceIdentifier,
resourcePath:
'../../../victim-ws/victim-app/built-front-component/secret.mjs',
}),
).toThrow(
expect.objectContaining({
code: FileStorageExceptionCode.ACCESS_DENIED,
}),
);
expect(mockDriver.readFile).not.toHaveBeenCalled();
});
it('should reject absolute paths', () => {
expect(() =>
service.readFile({
...validResourceIdentifier,
resourcePath: '/etc/passwd',
}),
).toThrow(
expect.objectContaining({
code: FileStorageExceptionCode.ACCESS_DENIED,
}),
);
expect(mockDriver.readFile).not.toHaveBeenCalled();
});
it('should reject single-level traversal escaping fileFolder', () => {
expect(() =>
service.readFile({
...validResourceIdentifier,
resourcePath: '../source/handler.ts',
}),
).toThrow(
expect.objectContaining({
code: FileStorageExceptionCode.ACCESS_DENIED,
}),
);
expect(mockDriver.readFile).not.toHaveBeenCalled();
});
});
describe('checkFileExists', () => {
it('should allow valid relative paths', async () => {
await service.checkFileExists(validResourceIdentifier);
expect(mockDriver.checkFileExists).toHaveBeenCalledWith({
filePath: expectedValidPath,
});
});
it('should reject path traversal with ../', () => {
expect(() =>
service.checkFileExists({
...validResourceIdentifier,
resourcePath:
'../../../other-ws/other-app/built-front-component/file.js',
}),
).toThrow(
expect.objectContaining({
code: FileStorageExceptionCode.ACCESS_DENIED,
}),
);
expect(mockDriver.checkFileExists).not.toHaveBeenCalled();
});
});
describe('getPresignedUrl', () => {
it('should reject path traversal', async () => {
await expect(
service.getPresignedUrl({
...validResourceIdentifier,
resourcePath: '../../../other-ws/file.js',
}),
).rejects.toMatchObject({
code: FileStorageExceptionCode.ACCESS_DENIED,
});
expect(mockDriver.getPresignedUrl).not.toHaveBeenCalled();
});
});
describe('downloadFile', () => {
it('should reject path traversal', () => {
expect(() =>
service.downloadFile({
...validResourceIdentifier,
resourcePath: '../../../other-ws/file.js',
localPath: '/tmp/download.js',
}),
).toThrow(
expect.objectContaining({
code: FileStorageExceptionCode.ACCESS_DENIED,
}),
);
expect(mockDriver.downloadFile).not.toHaveBeenCalled();
});
});
describe('writeFile', () => {
it('should reject path traversal on write', async () => {
await expect(
service.writeFile({
...validResourceIdentifier,
resourcePath:
'../../../victim-ws/victim-app/built-front-component/overwrite.mjs',
sourceFile: Buffer.from('malicious'),
mimeType: 'application/javascript',
settings: { isTemporaryFile: false, toDelete: false },
}),
).rejects.toMatchObject({
code: FileStorageExceptionCode.ACCESS_DENIED,
});
expect(mockDriver.writeFile).not.toHaveBeenCalled();
});
it('should allow valid writes', async () => {
await service.writeFile({
...validResourceIdentifier,
sourceFile: Buffer.from('valid content'),
mimeType: 'application/javascript',
settings: { isTemporaryFile: false, toDelete: false },
});
expect(mockDriver.writeFile).toHaveBeenCalledWith(
expect.objectContaining({
filePath: expectedValidPath,
}),
);
});
});
describe('delete', () => {
it('should reject path traversal on delete', async () => {
await expect(
service.delete({
...validResourceIdentifier,
resourcePath: '../../../other-ws/other-app/folder',
}),
).rejects.toMatchObject({
code: FileStorageExceptionCode.ACCESS_DENIED,
});
expect(mockDriver.delete).not.toHaveBeenCalled();
});
});
describe('copy', () => {
it('should reject path traversal in source', async () => {
await expect(
service.copy({
from: {
...validResourceIdentifier,
resourcePath: '../../../other-ws/secret.mjs',
},
to: validResourceIdentifier,
}),
).rejects.toMatchObject({
code: FileStorageExceptionCode.ACCESS_DENIED,
});
expect(mockDriver.copy).not.toHaveBeenCalled();
});
it('should reject path traversal in destination', async () => {
mockDriver.checkFileExists.mockResolvedValue(true);
await expect(
service.copy({
from: validResourceIdentifier,
to: {
...validResourceIdentifier,
resourcePath: '../../../other-ws/overwrite.mjs',
},
}),
).rejects.toMatchObject({
code: FileStorageExceptionCode.ACCESS_DENIED,
});
});
});
describe('edge cases', () => {
it('should reject traversal with excess .. segments', () => {
expect(() =>
service.readFile({
...validResourceIdentifier,
resourcePath: 'foo/../../../../../../etc/passwd',
}),
).toThrow(
expect.objectContaining({
code: FileStorageExceptionCode.ACCESS_DENIED,
}),
);
});
it('should accept deeply nested valid paths', async () => {
await service.readFile({
...validResourceIdentifier,
resourcePath: 'a/b/c/d/e/f/deep-file.mjs',
});
expect(mockDriver.readFile).toHaveBeenCalledWith({
filePath:
'workspace-123/app-456/built-front-component/a/b/c/d/e/f/deep-file.mjs',
});
});
it('should reject exact 3-level traversal to another tenant', () => {
expect(() =>
service.readFile({
...validResourceIdentifier,
resourcePath:
'../../../target-ws/target-app/built-front-component/file.js',
}),
).toThrow(
expect.objectContaining({
code: FileStorageExceptionCode.ACCESS_DENIED,
}),
);
});
it('should accept paths with dots that are not traversal', async () => {
await service.readFile({
...validResourceIdentifier,
resourcePath: '.hidden/file.name.ext',
});
expect(mockDriver.readFile).toHaveBeenCalled();
});
it('should reject empty resource path', () => {
expect(() =>
service.readFile({
...validResourceIdentifier,
resourcePath: '',
}),
).toThrow(
expect.objectContaining({
code: FileStorageExceptionCode.ACCESS_DENIED,
}),
);
expect(mockDriver.readFile).not.toHaveBeenCalled();
});
});
});
});
@@ -9,6 +9,8 @@ import { Like, Repository, type QueryRunner } from 'typeorm';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { FileStorageDriverFactory } from 'src/engine/core-modules/file-storage/file-storage-driver.factory';
import { assertStoragePathIsWithinWorkspace } from 'src/engine/core-modules/file-storage/utils/assert-storage-path-is-within-workspace.util';
import { assertResourcePathIsSafe } from 'src/engine/core-modules/file-storage/utils/assert-resource-path-is-safe.util';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { FileSettings } from 'src/engine/core-modules/file/types/file-settings.types';
@@ -35,12 +37,23 @@ export class FileStorageService {
fileFolder,
resourcePath,
}: ResourceIdentifier): string {
return join(
assertResourcePathIsSafe(resourcePath);
const onStoragePath = join(
workspaceId,
applicationUniversalIdentifier,
fileFolder,
resourcePath,
).replace(/\/+/g, '/');
assertStoragePathIsWithinWorkspace({
onStoragePath,
workspaceId,
applicationUniversalIdentifier,
fileFolder,
});
return onStoragePath;
}
async writeFile({
@@ -0,0 +1,53 @@
import { FileStorageExceptionCode } from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception';
import { assertResourcePathIsSafe } from 'src/engine/core-modules/file-storage/utils/assert-resource-path-is-safe.util';
describe('assertResourcePathIsSafe', () => {
it('should accept valid relative paths', () => {
expect(() =>
assertResourcePathIsSafe('src/components/test.mjs'),
).not.toThrow();
expect(() => assertResourcePathIsSafe('file.mjs')).not.toThrow();
expect(() => assertResourcePathIsSafe('a/b/c/d.txt')).not.toThrow();
});
it('should reject paths with .. traversal', () => {
expect(() => assertResourcePathIsSafe('../../../other-ws/file.js')).toThrow(
expect.objectContaining({
code: FileStorageExceptionCode.ACCESS_DENIED,
}),
);
});
it('should reject absolute paths', () => {
expect(() => assertResourcePathIsSafe('/etc/passwd')).toThrow(
expect.objectContaining({
code: FileStorageExceptionCode.ACCESS_DENIED,
}),
);
});
it('should reject paths with null bytes', () => {
expect(() => assertResourcePathIsSafe('file\0.txt')).toThrow(
expect.objectContaining({
code: FileStorageExceptionCode.ACCESS_DENIED,
}),
);
});
it('should reject paths with backslashes', () => {
expect(() => assertResourcePathIsSafe('..\\..\\etc\\passwd')).toThrow(
expect.objectContaining({
code: FileStorageExceptionCode.ACCESS_DENIED,
}),
);
});
it('should reject empty strings', () => {
expect(() => assertResourcePathIsSafe('')).toThrow(
expect.objectContaining({
code: FileStorageExceptionCode.ACCESS_DENIED,
}),
);
});
});
@@ -0,0 +1,86 @@
import { FileFolder } from 'twenty-shared/types';
import { FileStorageExceptionCode } from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception';
import { assertStoragePathIsWithinWorkspace } from 'src/engine/core-modules/file-storage/utils/assert-storage-path-is-within-workspace.util';
const primitives = {
workspaceId: 'workspace-id',
applicationUniversalIdentifier: 'app-uid',
fileFolder: FileFolder.BuiltFrontComponent,
};
describe('assertStoragePathIsWithinWorkspace', () => {
it('should accept paths within the expected prefix', () => {
expect(() =>
assertStoragePathIsWithinWorkspace({
onStoragePath:
'workspace-id/app-uid/built-front-component/src/component.mjs',
...primitives,
}),
).not.toThrow();
});
it('should accept paths directly under the prefix', () => {
expect(() =>
assertStoragePathIsWithinWorkspace({
onStoragePath: 'workspace-id/app-uid/built-front-component/file.mjs',
...primitives,
}),
).not.toThrow();
});
it('should reject paths that escape via .. traversal', () => {
expect(() =>
assertStoragePathIsWithinWorkspace({
onStoragePath:
'other-workspace/other-app/built-front-component/stolen.mjs',
...primitives,
}),
).toThrow(
expect.objectContaining({
code: FileStorageExceptionCode.ACCESS_DENIED,
}),
);
});
it('should reject paths that escape by one level', () => {
expect(() =>
assertStoragePathIsWithinWorkspace({
onStoragePath: 'workspace-id/app-uid/other-folder/file.mjs',
...primitives,
}),
).toThrow(
expect.objectContaining({
code: FileStorageExceptionCode.ACCESS_DENIED,
}),
);
});
it('should reject the prefix itself without a trailing file', () => {
expect(() =>
assertStoragePathIsWithinWorkspace({
onStoragePath: 'workspace-id/app-uid/built-front-component',
...primitives,
}),
).toThrow(
expect.objectContaining({
code: FileStorageExceptionCode.ACCESS_DENIED,
}),
);
});
it('should reject paths where prefix is a partial match', () => {
expect(() =>
assertStoragePathIsWithinWorkspace({
onStoragePath:
'workspace-id/app-uid/built-front-componentMalicious/file.mjs',
...primitives,
}),
).toThrow(
expect.objectContaining({
code: FileStorageExceptionCode.ACCESS_DENIED,
}),
);
});
});
@@ -0,0 +1,42 @@
import { isSafeRelativePath } from 'src/engine/core-modules/file-storage/utils/is-safe-relative-path.util';
describe('isSafeRelativePath', () => {
it('should accept valid relative paths', () => {
expect(isSafeRelativePath('src/components/my-component.mjs')).toBe(true);
expect(isSafeRelativePath('file.mjs')).toBe(true);
expect(isSafeRelativePath('a/b/c/d.txt')).toBe(true);
expect(isSafeRelativePath('.hidden-file')).toBe(true);
expect(isSafeRelativePath('folder/.gitignore')).toBe(true);
expect(isSafeRelativePath('file.name.ext')).toBe(true);
});
it('should reject paths with .. traversal segments', () => {
expect(isSafeRelativePath('../etc/passwd')).toBe(false);
expect(isSafeRelativePath('folder/../../etc/passwd')).toBe(false);
expect(isSafeRelativePath('..')).toBe(false);
expect(
isSafeRelativePath(
'../../../other-ws/other-app/BuiltFrontComponent/file.js',
),
).toBe(false);
});
it('should reject paths with null bytes', () => {
expect(isSafeRelativePath('file\0.txt')).toBe(false);
expect(isSafeRelativePath('folder/\0/file.txt')).toBe(false);
});
it('should reject absolute paths', () => {
expect(isSafeRelativePath('/etc/passwd')).toBe(false);
expect(isSafeRelativePath('/tmp/file.txt')).toBe(false);
});
it('should reject paths with backslashes', () => {
expect(isSafeRelativePath('folder\\file.txt')).toBe(false);
expect(isSafeRelativePath('..\\..\\etc\\passwd')).toBe(false);
});
it('should reject empty strings', () => {
expect(isSafeRelativePath('')).toBe(false);
});
});
@@ -0,0 +1,14 @@
import {
FileStorageException,
FileStorageExceptionCode,
} from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception';
import { isSafeRelativePath } from 'src/engine/core-modules/file-storage/utils/is-safe-relative-path.util';
export const assertResourcePathIsSafe = (resourcePath: string): void => {
if (!isSafeRelativePath(resourcePath)) {
throw new FileStorageException(
'Invalid resource path: contains unsafe characters or path traversal',
FileStorageExceptionCode.ACCESS_DENIED,
);
}
};
@@ -0,0 +1,36 @@
import { join, normalize } from 'path';
import { type FileFolder } from 'twenty-shared/types';
import {
FileStorageException,
FileStorageExceptionCode,
} from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception';
export const assertStoragePathIsWithinWorkspace = ({
onStoragePath,
workspaceId,
applicationUniversalIdentifier,
fileFolder,
}: {
onStoragePath: string;
workspaceId: string;
applicationUniversalIdentifier: string;
fileFolder: FileFolder;
}): void => {
const expectedPrefix = join(
workspaceId,
applicationUniversalIdentifier,
fileFolder,
);
const normalizedPath = normalize(onStoragePath);
const normalizedPrefix = normalize(expectedPrefix + '/');
if (!normalizedPath.startsWith(normalizedPrefix)) {
throw new FileStorageException(
'Invalid storage path: resolved path escapes the expected directory',
FileStorageExceptionCode.ACCESS_DENIED,
);
}
};
@@ -0,0 +1,27 @@
import { isAbsolute, normalize, sep } from 'path';
export const isSafeRelativePath = (filePath: string): boolean => {
if (filePath.length === 0) {
return false;
}
if (filePath.includes('\0')) {
return false;
}
if (isAbsolute(filePath)) {
return false;
}
if (filePath.includes('\\')) {
return false;
}
const normalized = normalize(filePath);
if (normalized.split(sep).includes('..')) {
return false;
}
return true;
};
@@ -0,0 +1,33 @@
import 'reflect-metadata';
import { isDefined } from 'twenty-shared/utils';
export const defineUpgradeMetadataOnClassOrProperty = <T>({
classMetadataKey,
propertyMetadataKey,
value,
target,
propertyKey,
}: {
classMetadataKey: string;
propertyMetadataKey: string;
value: T;
target: object;
propertyKey: string | symbol | undefined;
}): void => {
if (!isDefined(propertyKey)) {
Reflect.defineMetadata(classMetadataKey, value, target);
return;
}
const constructor = (target as { constructor: Function }).constructor;
const existing: Record<string, T> =
Reflect.getMetadata(propertyMetadataKey, constructor) ?? {};
Reflect.defineMetadata(
propertyMetadataKey,
{ ...existing, [String(propertyKey)]: value },
constructor,
);
};
@@ -0,0 +1,43 @@
import 'reflect-metadata';
import { defineUpgradeMetadataOnClassOrProperty } from 'src/engine/core-modules/upgrade/decorators/upgrade-decorator-metadata.util';
export type WasIntroducedInUpgradeOptions = {
upgradeCommandName: string;
};
export const WAS_INTRODUCED_IN_UPGRADE_CLASS_METADATA_KEY =
'WAS_INTRODUCED_IN_UPGRADE_CLASS';
export const WAS_INTRODUCED_IN_UPGRADE_PROPERTIES_METADATA_KEY =
'WAS_INTRODUCED_IN_UPGRADE_PROPERTIES';
export type WasIntroducedInUpgradePropertyMap = Record<
string,
WasIntroducedInUpgradeOptions
>;
export const WasIntroducedInUpgrade =
(options: WasIntroducedInUpgradeOptions) =>
(target: object, propertyKey?: string | symbol): void => {
defineUpgradeMetadataOnClassOrProperty({
classMetadataKey: WAS_INTRODUCED_IN_UPGRADE_CLASS_METADATA_KEY,
propertyMetadataKey: WAS_INTRODUCED_IN_UPGRADE_PROPERTIES_METADATA_KEY,
value: options,
target,
propertyKey,
});
};
export const getWasIntroducedInUpgradeClassMetadata = (
target: Function,
): WasIntroducedInUpgradeOptions | undefined =>
Reflect.getMetadata(WAS_INTRODUCED_IN_UPGRADE_CLASS_METADATA_KEY, target);
export const getWasIntroducedInUpgradePropertyMetadata = (
target: Function,
): WasIntroducedInUpgradePropertyMap =>
Reflect.getMetadata(
WAS_INTRODUCED_IN_UPGRADE_PROPERTIES_METADATA_KEY,
target,
) ?? {};
@@ -0,0 +1,42 @@
import 'reflect-metadata';
import { defineUpgradeMetadataOnClassOrProperty } from 'src/engine/core-modules/upgrade/decorators/upgrade-decorator-metadata.util';
export type WasRenamedInUpgradeHistoryEntry = {
previousName: string;
upgradeCommandName: string;
};
export const WAS_RENAMED_IN_UPGRADE_CLASS_METADATA_KEY =
'WAS_RENAMED_IN_UPGRADE_CLASS';
export const WAS_RENAMED_IN_UPGRADE_PROPERTIES_METADATA_KEY =
'WAS_RENAMED_IN_UPGRADE_PROPERTIES';
export type WasRenamedInUpgradePropertyMap = Record<
string,
WasRenamedInUpgradeHistoryEntry[]
>;
export const WasRenamedInUpgrade =
(history: WasRenamedInUpgradeHistoryEntry[]) =>
(target: object, propertyKey?: string | symbol): void => {
defineUpgradeMetadataOnClassOrProperty({
classMetadataKey: WAS_RENAMED_IN_UPGRADE_CLASS_METADATA_KEY,
propertyMetadataKey: WAS_RENAMED_IN_UPGRADE_PROPERTIES_METADATA_KEY,
value: history,
target,
propertyKey,
});
};
export const getWasRenamedInUpgradeClassMetadata = (
target: Function,
): WasRenamedInUpgradeHistoryEntry[] | undefined =>
Reflect.getMetadata(WAS_RENAMED_IN_UPGRADE_CLASS_METADATA_KEY, target);
export const getWasRenamedInUpgradePropertyMetadata = (
target: Function,
): WasRenamedInUpgradePropertyMap =>
Reflect.getMetadata(WAS_RENAMED_IN_UPGRADE_PROPERTIES_METADATA_KEY, target) ??
{};
@@ -18,6 +18,7 @@ import {
} from 'src/engine/core-modules/upgrade/services/upgrade-sequence-reader.service';
import { WorkspaceCommandRunnerService } from 'src/engine/core-modules/upgrade/services/workspace-command-runner.service';
import { formatUpgradeLog } from 'src/engine/core-modules/upgrade/utils/format-upgrade-log.util';
import { UpgradeAwareEntityMetadataAdapter } from 'src/engine/twenty-orm/upgrade-aware/upgrade-aware-entity-metadata.adapter';
import { WorkspaceVersionService } from 'src/engine/workspace-manager/workspace-version/services/workspace-version.service';
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
@@ -35,6 +36,7 @@ export class UpgradeSequenceRunnerService {
private readonly instanceCommandRunnerService: InstanceCommandRunnerService,
private readonly workspaceCommandRunnerService: WorkspaceCommandRunnerService,
private readonly upgradeSequenceReaderService: UpgradeSequenceReaderService,
private readonly upgradeAwareEntityMetadataAdapter: UpgradeAwareEntityMetadataAdapter,
private readonly workspaceIteratorService: WorkspaceIteratorService,
private readonly workspaceVersionService: WorkspaceVersionService,
) {}
@@ -50,6 +52,31 @@ export class UpgradeSequenceRunnerService {
return { totalSuccesses: 0, totalFailures: 0 };
}
await this.upgradeAwareEntityMetadataAdapter.refresh();
try {
return await this.runInner({ sequence, options });
} finally {
try {
await this.upgradeAwareEntityMetadataAdapter.refresh();
} catch (refreshError) {
this.logger.error(
`Failed to refresh upgrade-aware entity metadata after run`,
refreshError instanceof Error
? refreshError.stack
: String(refreshError),
);
}
}
}
private async runInner({
sequence,
options,
}: {
sequence: UpgradeStep[];
options: ParsedUpgradeCommandOptions;
}): Promise<UpgradeSequenceRunnerReport> {
const allActiveOrSuspendedWorkspaceIds =
await this.workspaceVersionService.getActiveOrSuspendedWorkspaceIds();
@@ -107,6 +134,8 @@ export class UpgradeSequenceRunnerService {
skipDataMigration: allActiveOrSuspendedWorkspaceIds.length === 0,
});
await this.upgradeAwareEntityMetadataAdapter.refresh();
cursor++;
continue;
}
@@ -18,6 +18,7 @@ import { WorkspaceCommandRunnerService } from 'src/engine/core-modules/upgrade/s
import { UpgradeGaugeService } from 'src/engine/core-modules/upgrade/upgrade-gauge.service';
import { UpgradeMigrationEntity } from 'src/engine/core-modules/upgrade/upgrade-migration.entity';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { UpgradeAwareEntityMetadataAdapter } from 'src/engine/twenty-orm/upgrade-aware/upgrade-aware-entity-metadata.adapter';
import { WorkspaceVersionModule } from 'src/engine/workspace-manager/workspace-version/workspace-version.module';
@Module({
@@ -36,6 +37,7 @@ import { WorkspaceVersionModule } from 'src/engine/workspace-manager/workspace-v
InstanceCommandRunnerService,
WorkspaceCommandRunnerService,
UpgradeCommandRegistryService,
UpgradeAwareEntityMetadataAdapter,
UpgradeSequenceReaderService,
UpgradeSequenceRunnerService,
UpgradeStatusService,
@@ -47,6 +49,7 @@ import { WorkspaceVersionModule } from 'src/engine/workspace-manager/workspace-v
InstanceCommandRunnerService,
WorkspaceCommandRunnerService,
UpgradeCommandRegistryService,
UpgradeAwareEntityMetadataAdapter,
UpgradeSequenceReaderService,
UpgradeSequenceRunnerService,
UpgradeStatusService,

Some files were not shown because too many files have changed in this diff Show More