Compare commits

..
Author SHA1 Message Date
Sonarly Claude Code 136daecbf5 fix: enforce API key name validation on creation and detail page
https://sonarly.com/issue/18288?type=bug

Users can create API keys without a name because `isDefined(canSave)` evaluates to `true` even when `canSave` is `false`. The resulting nameless API key renders as a blank page because the detail page conditionally renders only when `apiKey?.name` is truthy.

Fix: Two changes fix both the cause and the symptom:

**1. `SettingsDevelopersApiKeysNew.tsx` — Restore correct save button validation**

Changed `isSaveDisabled={!isDefined(canSave)}` back to `isSaveDisabled={!canSave}`. The `isDefined` wrapper was incorrectly added during the ESLint-to-OxLint migration (commit `9d57bc39e5d`). Since `canSave` is a boolean, `isDefined(false)` returns `true`, which meant the save button was always enabled. The original `!canSave` correctly disables save when the name is empty.

Also added `if (!formValues.name) return;` guard at the top of `handleSave()` as defense-in-depth, since the Enter key handler on the name input calls `handleSave()` directly without checking the `canSave` flag.

**2. `SettingsDevelopersApiKeyDetail.tsx` — Render page for nameless API keys**

Changed the render guard from `{apiKey?.name && (` to `{isDefined(apiKey) && (`. The old guard treated an empty-string name as falsy, hiding the entire page content. The new guard correctly checks for data presence. The title and breadcrumb now fall back to a translated `"Unnamed API Key"` label when the name is empty, keeping the page usable so users can either rename or delete the broken API key.
2026-03-25 13:39:33 +00:00
e25ea6069d i18n - translations (#18958)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-25 14:31:01 +01:00
f7ef41959b i18n - translations (#18956)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-25 14:23:30 +01:00
ba0108944f i18n - translations (#18955)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-25 14:11:05 +01:00
Félix MalfaitandGitHub 03c94727be fix: wrap standard object/field metadata labels in msg for i18n extraction (#18951)
## Summary

- Standard object and field metadata labels were plain strings instead
of being wrapped in Lingui `msg` template literals, which prevented
extraction into translation catalogs. This caused "Uncompiled message
detected!" warnings at runtime.
- The bug was introduced when the decorator-based approach
(`@WorkspaceEntity({ labelSingular: msg`...` })`) was replaced by flat
metadata builder utils with plain strings.
- Adds an `i18nLabel` helper to safely extract the message string from
`MessageDescriptor` objects.
- Re-runs `lingui extract` and `lingui compile` to update all locale PO
files and compiled catalogs.
- Adds an integration test that queries the metadata API with `x-locale`
headers to verify locale-aware label resolution.
- Includes a ClickHouse usage event writer integration test (small
unrelated fix noticed along the way).

## Test plan

- [ ] CI lint passes (prettier + oxlint)
- [ ] CI typecheck passes
- [ ] Server unit tests pass
- [ ] Integration tests pass (including new `object-metadata-i18n` test)
- [ ] No "Uncompiled message detected!" warnings for standard
object/field labels at runtime


Made with [Cursor](https://cursor.com)
2026-03-25 14:03:51 +01:00
MarieandGitHub bf22373315 Fix: use user role for OAuth tokens bearing user context (#18954)
see [discord
discussion](https://discord.com/channels/1130383047699738754/1486299347091198054/1486299351520383116)

## Summary

When an OAuth application token carries both `applicationId` and
`userId`/`userWorkspaceId`, the auth context now uses the **user's
role** for permissions instead of the application's `defaultRoleId`.

This fixes the case where external clients authenticating via OAuth
(e.g. a client's external AI chat) were getting the app's permissions
instead of the authenticated user's.

### What changed

- **`jwt.auth.strategy.ts`** (`validateApplicationToken`): when an
application token includes user info, also resolve `workspaceMemberId`
and `workspaceMember` from the workspace cache (same pattern as
`validateAccessToken`)
- **`workspace-auth-context.middleware.ts`** (`buildAuthContext`): when
both `application` and `user` (with
`workspaceMemberId`/`workspaceMember`) are present on the request, build
a `UserWorkspaceAuthContext` instead of
`ApplicationWorkspaceAuthContext`. Falls back to application context if
workspace member cannot be resolved.

### Places impacted by this change (no code changes, behavior changes)

These places check `isApplicationAuthContext` or resolve roles from auth
context. Since hybrid tokens (OAuth with user) now produce a
`UserWorkspaceAuthContext`, they naturally flow into the
`isUserAuthContext` branches:

| File | Impact |
|------|--------|
| `permissions.service.ts` —
`resolveRolePermissionConfigFromAuthContext` | OAuth+user now uses
user's role via `isUserAuthContext` branch instead of
`application.defaultRoleId` |
| `common-api-context-builder.service.ts` — `getObjectsPermissions` |
Same — OAuth+user falls into `isUserAuthContext` branch |
| `common-base-query-runner.service.ts` — `getRoleIdOrThrow` | Same —
OAuth+user falls into `isUserAuthContext` branch |
| `actor-from-auth-context.service.ts` — `buildActorMetadata` | Records
created via OAuth+user will show the **user's name** as actor instead of
the application's name |
| `message-find-one.post-query.hook.ts` | OAuth+user now passes the
`isUserAuthContext` check (previously would fail unless it was the
Twenty standard application) |
| `front-component.resolver.ts` | Front component tokens include
`userId` — they will now correctly use the user's role, fixing a
pre-existing permission escalation where a user could access data
through a front component's app role that exceeded their own |
| `logic-function-executor.service.ts` | **Not impacted** — only
generates tokens with `applicationId` (no `userId`) |
2026-03-25 12:38:01 +00:00
Thomas TrompetteandGitHub e1374e34a7 Fix object permission override (#18948)
Issue: https://www.loom.com/share/dd48cd509f614e51829f6a5b58d41b6b

Bug: Unsetting a revoked object permission keeps it revoked
When a role has a global permission enabled (e.g.
canReadAllObjectRecords: true) but an object-level override revokes it
(canReadObjectRecords: false), clicking to remove that override had no
effect — the permission stayed revoked after save.

Root cause:
Backend (object-permission.service.ts): The nullish coalescing operator
(??) was used to fall back to the current DB value when the input didn't
provide a value. Since ?? treats both null and undefined as nullish,
sending canReadObjectRecords: null (meaning "remove override") was
coalesced to the current value (false), silently discarding the reset.

Fix:
- Backend: Replaced ?? with explicit !== undefined checks, so null is
preserved as a meaningful value (meaning "no override / inherit from
global") while undefined (field not provided) still falls back to the
current value. This also fixes the "Reset all permissions" flow which
sends null for all permission fields.

Additional frontend fix: Changed !value to value === false so that only
an explicit false cascades revocation to write permissions. Setting null
(reset to inherit) now only affects the read permission itself.
2026-03-25 10:49:05 +00:00
Paul RastoinandGitHub 523289efad Do not rollback on cache invalidation failure in workspace migration runner (#18947) 2026-03-25 10:30:16 +00:00
neo773andGitHub e6bb39deea fix: reset throttle state on channel relaunch (#18843)
Relaunch jobs reset syncStage/syncStatus but not throttleFailureCount
2026-03-25 09:52:17 +00:00
Charles BochetandGitHub 790a58945b Migrate twenty-companion from npm to yarn workspaces (#18946)
## Summary
- Migrates twenty-companion from standalone npm to the repo yarn
workspaces
- Removes package-lock.json (resolves Oneleet security finding about npm
lifecycle scripts)
- Converts npm overrides to yarn resolutions
- Updates scripts from npm run to yarn

## Test plan
- [x] Verified yarn install succeeds at root
- [x] Verified yarn start in twenty-companion launches the Electron app
- [ ] Verify Oneleet finding is resolved after merge
2026-03-25 10:45:43 +01:00
3295f5ee07 fix logo upload during workspace onboarding (#18905)
Logo upload during onboarding failed because it required the Twenty
Standard Application, which doesn't exist yet at that point

We only need custom app's universalIdentifier
(workspace.workspaceCustomApplicationId, available from sign up) so we
can safely remove ApplicationService dependency


https://github.com/user-attachments/assets/a18599ee-0b91-4629-ad77-2f708351449a


/closes #18829

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-03-25 08:02:08 +00:00
Abdul RahmanandGitHub 2bfd2f6b85 fix navigation menu item overflow issue (#18937)
Before
<img width="231" height="99" alt="Screenshot 2026-03-25 at 5 34 59 AM"
src="https://github.com/user-attachments/assets/47980a03-b2ec-47db-be16-db0a48b122dd"
/>


After
<img width="220" height="96" alt="Screenshot 2026-03-25 at 5 32 44 AM"
src="https://github.com/user-attachments/assets/5ad405c2-a697-407d-ac1e-450c4a0b9f62"
/>
2026-03-25 07:35:49 +00:00
5de269a64e i18n - docs translations (#18942)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-25 05:52:30 +01:00
cc7131b0b5 i18n - docs translations (#18941)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-25 04:18:13 +01:00
186 changed files with 139594 additions and 46332 deletions
+2 -1
View File
@@ -211,7 +211,8 @@
"packages/twenty-apps",
"packages/twenty-cli",
"packages/create-twenty-app",
"packages/twenty-oxlint-rules"
"packages/twenty-oxlint-rules",
"packages/twenty-companion"
]
},
"prettier": {
File diff suppressed because it is too large Load Diff
+3 -5
View File
@@ -5,7 +5,7 @@
"description": "Twenty meeting recorder",
"main": ".webpack/main",
"scripts": {
"start": "concurrently \"npm run start:server\" \"npm run start:electron\"",
"start": "concurrently \"yarn start:server\" \"yarn start:electron\"",
"start:electron": "electron-forge start",
"package": "electron-forge package",
"make": "electron-forge make",
@@ -37,10 +37,8 @@
"node-loader": "^2.1.0",
"style-loader": "^3.3.4"
},
"overrides": {
"@electron/packager": {
"@electron/osx-sign": "github:recallai/osx-sign"
}
"resolutions": {
"@electron/packager/@electron/osx-sign": "github:recallai/osx-sign"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.40.1",
@@ -574,7 +574,7 @@ export default defineLogicFunction({
timeoutSeconds: 2,
handler,
triggers: [
// Public HTTP route trigger '/s/post-card/create'
// Öffentlicher HTTP-Routen-Trigger '/s/post-card/create'
{
universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6',
type: 'route',
@@ -582,13 +582,13 @@ export default defineLogicFunction({
httpMethod: 'GET',
isAuthRequired: false,
},
// Cron trigger (CRON pattern)
// Cron-Trigger (CRON-Muster)
// {
// universalIdentifier: 'dd802808-0695-49e1-98c9-d5c9e2704ce2',
// type: 'cron',
// pattern: '0 0 1 1 *',
// },
// Database event trigger
// Datenbank-Ereignis-Trigger
// {
// universalIdentifier: '203f1df3-4a82-4d06-a001-b8cf22a31156',
// type: 'databaseEvent',
@@ -804,8 +804,8 @@ const handler = async (params: { companyName: string; domain?: string }) => {
createTask: {
__args: {
data: {
title: `Enrich data for ${params.companyName}`,
body: `Domain: ${params.domain ?? 'unknown'}`,
title: `Daten für ${params.companyName} anreichern`,
body: `Domain: ${params.domain ?? 'unbekannt'}`,
},
},
id: true,
@@ -818,7 +818,7 @@ const handler = async (params: { companyName: string; domain?: string }) => {
export default defineLogicFunction({
universalIdentifier: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
name: 'enrich-company',
description: 'Enrich a company record with external data',
description: 'Einen Unternehmensdatensatz mit externen Daten anreichern',
timeoutSeconds: 10,
handler,
isTool: true,
@@ -827,11 +827,11 @@ export default defineLogicFunction({
properties: {
companyName: {
type: 'string',
description: 'The name of the company to enrich',
description: 'Name des Unternehmens, das angereichert werden soll',
},
domain: {
type: 'string',
description: 'The company website domain (optional)',
description: 'Website-Domain des Unternehmens (optional)',
},
},
required: ['companyName'],
@@ -1221,7 +1221,7 @@ const client = new CoreApiClient();
const { me } = await client.query({ me: { id: true, displayName: true } });
const metadataClient = new MetadataApiClient();
const { currentWorkspace } = await metadataClient.query({ currentWorkspace: { id: true } });
const { currentWorkspace } = await metadataClient.query({ currentWorkspace: { id: true } });},{
```
`CoreApiClient` wird von `yarn twenty dev` automatisch neu generiert, sobald sich Ihre Objekte oder Felder ändern. `MetadataApiClient` ist im SDK bereits enthalten.
@@ -1252,10 +1252,10 @@ const metadataClient = new MetadataApiClient();
const fileBuffer = fs.readFileSync('./invoice.pdf');
const uploadedFile = await metadataClient.uploadFile(
fileBuffer, // file contents as a Buffer
'invoice.pdf', // filename
'application/pdf', // MIME type (defaults to 'application/octet-stream')
'58a0a314-d7ea-4865-9850-7fb84e72f30b', // field universal identifier
fileBuffer, // Dateiinhalte als Buffer
'invoice.pdf', // Dateiname
'application/pdf', // MIME-Typ (Standard: 'application/octet-stream')
'58a0a314-d7ea-4865-9850-7fb84e72f30b', // Universeller Feldbezeichner
);
console.log(uploadedFile);
@@ -574,7 +574,7 @@ export default defineLogicFunction({
timeoutSeconds: 2,
handler,
triggers: [
// Public HTTP route trigger '/s/post-card/create'
// Trigger di route HTTP pubblica '/s/post-card/create'
{
universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6',
type: 'route',
@@ -582,13 +582,13 @@ export default defineLogicFunction({
httpMethod: 'GET',
isAuthRequired: false,
},
// Cron trigger (CRON pattern)
// Trigger Cron (pattern CRON)
// {
// universalIdentifier: 'dd802808-0695-49e1-98c9-d5c9e2704ce2',
// type: 'cron',
// pattern: '0 0 1 1 *',
// },
// Database event trigger
// Trigger di evento del database
// {
// universalIdentifier: '203f1df3-4a82-4d06-a001-b8cf22a31156',
// type: 'databaseEvent',
@@ -804,8 +804,8 @@ const handler = async (params: { companyName: string; domain?: string }) => {
createTask: {
__args: {
data: {
title: `Enrich data for ${params.companyName}`,
body: `Domain: ${params.domain ?? 'unknown'}`,
title: `Arricchisci i dati per ${params.companyName}`,
body: `Dominio: ${params.domain ?? 'sconosciuto'}`,
},
},
id: true,
@@ -818,7 +818,7 @@ const handler = async (params: { companyName: string; domain?: string }) => {
export default defineLogicFunction({
universalIdentifier: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
name: 'enrich-company',
description: 'Enrich a company record with external data',
description: 'Arricchisci un record aziendale con dati esterni',
timeoutSeconds: 10,
handler,
isTool: true,
@@ -827,11 +827,11 @@ export default defineLogicFunction({
properties: {
companyName: {
type: 'string',
description: 'The name of the company to enrich',
description: 'Il nome dell\'azienda da arricchire',
},
domain: {
type: 'string',
description: 'The company website domain (optional)',
description: 'Il dominio del sito web dell\'azienda (facoltativo)',
},
},
required: ['companyName'],
@@ -1221,7 +1221,7 @@ const client = new CoreApiClient();
const { me } = await client.query({ me: { id: true, displayName: true } });
const metadataClient = new MetadataApiClient();
const { currentWorkspace } = await metadataClient.query({ currentWorkspace: { id: true } });
const { currentWorkspace } = await metadataClient.query({ currentWorkspace: { id: true } });},{
```
`CoreApiClient` viene rigenerato automaticamente da `yarn twenty dev` ogni volta che i tuoi oggetti o campi cambiano. `MetadataApiClient` è fornito pronto all'uso con l'SDK.
@@ -1252,10 +1252,10 @@ const metadataClient = new MetadataApiClient();
const fileBuffer = fs.readFileSync('./invoice.pdf');
const uploadedFile = await metadataClient.uploadFile(
fileBuffer, // file contents as a Buffer
'invoice.pdf', // filename
'application/pdf', // MIME type (defaults to 'application/octet-stream')
'58a0a314-d7ea-4865-9850-7fb84e72f30b', // field universal identifier
fileBuffer, // contenuto del file come Buffer
'invoice.pdf', // nome del file
'application/pdf', // Tipo MIME (predefinito: 'application/octet-stream')
'58a0a314-d7ea-4865-9850-7fb84e72f30b', // identificatore universale del campo
);
console.log(uploadedFile);
@@ -596,7 +596,7 @@ export default defineLogicFunction({
// updatedFields: ['name'],
// },
],
});
});},{
```
Tipuri comune de declanșatoare:
@@ -596,7 +596,7 @@ export default defineLogicFunction({
// updatedFields: ['name'],
// },
],
});
});},{
```
Распространённые типы триггеров:
@@ -555,8 +555,8 @@ import { CoreApiClient, type Person } from 'twenty-client-sdk/core';
const handler = async (params: RoutePayload) => {
const client = new CoreApiClient();
const name = 'name' in params.queryStringParameters
? params.queryStringParameters.name ?? process.env.DEFAULT_RECIPIENT_NAME ?? 'Hello world'
: 'Hello world';
? params.queryStringParameters.name ?? process.env.DEFAULT_RECIPIENT_NAME ?? 'Merhaba dünya'
: 'Merhaba dünya';
const result = await client.mutation({
createPostCard: {
@@ -574,7 +574,7 @@ export default defineLogicFunction({
timeoutSeconds: 2,
handler,
triggers: [
// Public HTTP route trigger '/s/post-card/create'
// Herkese açık HTTP rota tetikleyicisi '/s/post-card/create'
{
universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6',
type: 'route',
@@ -582,13 +582,13 @@ export default defineLogicFunction({
httpMethod: 'GET',
isAuthRequired: false,
},
// Cron trigger (CRON pattern)
// Cron tetikleyicisi (CRON deseni)
// {
// universalIdentifier: 'dd802808-0695-49e1-98c9-d5c9e2704ce2',
// type: 'cron',
// pattern: '0 0 1 1 *',
// },
// Database event trigger
// Veritabanı olay tetikleyicisi
// {
// universalIdentifier: '203f1df3-4a82-4d06-a001-b8cf22a31156',
// type: 'databaseEvent',
@@ -804,8 +804,8 @@ const handler = async (params: { companyName: string; domain?: string }) => {
createTask: {
__args: {
data: {
title: `Enrich data for ${params.companyName}`,
body: `Domain: ${params.domain ?? 'unknown'}`,
title: `${params.companyName} için verileri zenginleştir`,
body: `Alan adı: ${params.domain ?? 'bilinmiyor'}`,
},
},
id: true,
@@ -818,7 +818,7 @@ const handler = async (params: { companyName: string; domain?: string }) => {
export default defineLogicFunction({
universalIdentifier: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
name: 'enrich-company',
description: 'Enrich a company record with external data',
description: 'Bir şirket kaydını harici verilerle zenginleştir',
timeoutSeconds: 10,
handler,
isTool: true,
@@ -827,11 +827,11 @@ export default defineLogicFunction({
properties: {
companyName: {
type: 'string',
description: 'The name of the company to enrich',
description: 'Zenginleştirilecek şirketin adı',
},
domain: {
type: 'string',
description: 'The company website domain (optional)',
description: 'Şirket web sitesi alan adı (isteğe bağlı)',
},
},
required: ['companyName'],
@@ -1221,7 +1221,7 @@ const client = new CoreApiClient();
const { me } = await client.query({ me: { id: true, displayName: true } });
const metadataClient = new MetadataApiClient();
const { currentWorkspace } = await metadataClient.query({ currentWorkspace: { id: true } });
const { currentWorkspace } = await metadataClient.query({ currentWorkspace: { id: true } });},{
```
`CoreApiClient`, nesneleriniz veya alanlarınız değiştiğinde `yarn twenty dev` tarafından otomatik olarak yeniden oluşturulur. `MetadataApiClient`, SDK ile birlikte önceden hazırlanmış olarak gelir.
@@ -1252,10 +1252,10 @@ const metadataClient = new MetadataApiClient();
const fileBuffer = fs.readFileSync('./invoice.pdf');
const uploadedFile = await metadataClient.uploadFile(
fileBuffer, // file contents as a Buffer
'invoice.pdf', // filename
'application/pdf', // MIME type (defaults to 'application/octet-stream')
'58a0a314-d7ea-4865-9850-7fb84e72f30b', // field universal identifier
fileBuffer, // dosya içeriği (Buffer olarak)
'invoice.pdf', // dosya adı
'application/pdf', // MIME türü (varsayılan: 'application/octet-stream')
'58a0a314-d7ea-4865-9850-7fb84e72f30b', // alanın evrensel tanımlayıcısı
);
console.log(uploadedFile);
+2 -2
View File
@@ -91,7 +91,7 @@ msgstr "Einladung akzeptieren"
#. js-lingui-explicit-id
#: src/emails/send-email-verification-link.email.tsx
msgid "Confirm your new email address"
msgstr ""
msgstr "Bestätige deine neue E-Mail-Adresse"
#. js-lingui-explicit-id
#: src/emails/send-email-verification-link.email.tsx
@@ -101,7 +101,7 @@ msgstr "Bestätigen Sie Ihre E-Mail-Adresse"
#. js-lingui-explicit-id
#: src/emails/send-email-verification-link.email.tsx
msgid "Confirm new email"
msgstr ""
msgstr "Neue E-Mail-Adresse bestätigen"
#. js-lingui-explicit-id
#: src/emails/send-email-verification-link.email.tsx
@@ -1 +1 @@
/*eslint-disable*/import type{Messages}from"@lingui/core";export const messages=JSON.parse("{\"Suspended Workspace\":[\"Ausgesetzter Arbeitsbereich\"],\"Dear {userName},\":[\"Sehr geehrte/r \",[\"userName\"],\",\"],\"Hello,\":[\"Hallo,\"],\"It appears that your workspace <0>{workspaceDisplayName}</0> has been suspended for {daysSinceInactive} days.\":[\"Es scheint, dass Ihr Workspace <0>\",[\"workspaceDisplayName\"],\"</0> seit \",[\"daysSinceInactive\"],\" Tagen gesperrt ist.\"],\"The workspace will be deactivated in {remainingDays} {dayOrDays}, and all its data will be deleted.\":[\"Der Workspace wird in \",[\"remainingDays\"],\" \",[\"dayOrDays\"],\" deaktiviert, und alle Daten werden gelöscht.\"],\"If you wish to continue using Twenty, please update your subscription within the next {remainingDays} {dayOrDays}.\":[\"Wenn Sie Twenty weiterhin nutzen möchten, aktualisieren Sie bitte Ihr Abonnement innerhalb der nächsten \",[\"remainingDays\"],\" \",[\"dayOrDays\"],\".\"],\"Update your subscription\":[\"Aktualisieren Sie Ihr Abonnement\"],\"Validate domain\":[\"Domain validieren\"],\"{senderName} (<0>{senderEmail}</0>): Please validate this domain to allow users with <1>@{domain}</1> email addresses to join your workspace without requiring an invitation.\":[[\"senderName\"],\" (<0>\",[\"senderEmail\"],\"</0>): Bitte validieren Sie diese Domain, damit Benutzer mit <1>@\",[\"domain\"],\"</1> E-Mail-Adressen Ihrem Arbeitsbereich beitreten können, ohne eine Einladung zu benötigen.\"],\"Test email\":[\"Test E-Mail\"],\"Join your team on Twenty\":[\"Treten Sie Ihrem Team auf Twenty bei\"],\"{senderName} (<0>{senderEmail}</0>) has invited you to join a workspace called <1>{workspaceName}</1>.\":[[\"senderName\"],\" (<0>\",[\"senderEmail\"],\"</0>) hat Sie eingeladen, einem Arbeitsbereich namens <1>\",[\"workspaceName\"],\"</1> beizutreten.\"],\"Accept invite\":[\"Einladung akzeptieren\"],\"Confirm your new email address\":[\"Confirm your new email address\"],\"Confirm your email address\":[\"Bestätigen Sie Ihre E-Mail-Adresse\"],\"Confirm new email\":[\"Confirm new email\"],\"Verify Email\":[\"E-Mail verifizieren\"],\"Password updated\":[\"Passwort wurde aktualisiert\"],\"This is a confirmation that password for your account ({email}) was successfully changed on {formattedDate}.\":[\"Dies ist eine Bestätigung, dass das Passwort für Ihr Konto (\",[\"email\"],\") am \",[\"formattedDate\"],\" erfolgreich geändert wurde.\"],\"If you did not initiate this change, please contact your workspace owner immediately.\":[\"Wenn Sie diese Änderung nicht veranlasst haben, kontaktieren Sie bitte umgehend den Eigentümer Ihres Workspaces.\"],\"Connect to Twenty\":[\"Mit Twenty verbinden\"],\"Reset your password 🗝\":[\"Setzen Sie Ihr Passwort zurück 🗝\"],\"Set your password 🗝\":[\"Setze dein Passwort 🗝\"],\"Reset\":[\"Zurücksetzen\"],\"Set\":[\"Setzen\"],\"This link is only valid for the next {duration}. If the link does not work, you can use the login verification link directly:\":[\"Dieser Link ist nur für die nächsten \",[\"duration\"],\" gültig. Wenn der Link nicht funktioniert, können Sie den Anmeldebestätigungslink direkt verwenden:\"],\"Deleted Workspace\":[\"Gelöschter Arbeitsbereich\"],\"Your workspace <0>{workspaceDisplayName}</0> has been deleted as your subscription expired {daysSinceInactive} days ago.\":[\"Ihr Workspace <0>\",[\"workspaceDisplayName\"],\"</0> wurde gelöscht, da Ihr Abonnement vor \",[\"daysSinceInactive\"],\" Tagen abgelaufen ist.\"],\"All data in this workspace has been permanently deleted.\":[\"Alle Daten in diesem Workspace wurden dauerhaft gelöscht.\"],\"If you wish to use Twenty again, you can create a new workspace.\":[\"Wenn Sie Twenty erneut nutzen möchten, können Sie einen neuen Workspace erstellen.\"],\"Create a new workspace\":[\"Erstellen Sie einen neuen Arbeitsbereich\"],\"What is Twenty?\":[\"Was ist Twenty?\"],\"It's a CRM, a software to help businesses manage their customer data and relationships efficiently.\":[\"Es ist ein CRM, eine Software, die Unternehmen hilft, ihre Kundendaten und -beziehungen effizient zu verwalten.\"],\"Website\":[\"Website\"],\"Visit Twenty's website\":[\"Besuchen Sie die Twenty-Website\"],\"Github\":[\"GitHub\"],\"Visit Twenty's GitHub repository\":[\"Besuchen Sie das GitHub-Repository von Twenty\"],\"User guide\":[\"Benutzerhandbuch\"],\"Read Twenty's user guide\":[\"Lesen Sie das Benutzerhandbuch von Twenty\"],\"Developers\":[\"Entwickler\"],\"Visit Twenty's developer documentation\":[\"Besuchen Sie die Entwicklerdokumentation von Twenty\"],\"Twenty.com, Public Benefit Corporation\":[\"Twenty.com, Gemeinnützige Aktiengesellschaft\"],\"San Francisco / Paris\":[\"San Francisco / Paris\"]}")as Messages;
/*eslint-disable*/import type{Messages}from"@lingui/core";export const messages=JSON.parse("{\"Suspended Workspace\":[\"Ausgesetzter Arbeitsbereich\"],\"Dear {userName},\":[\"Sehr geehrte/r \",[\"userName\"],\",\"],\"Hello,\":[\"Hallo,\"],\"It appears that your workspace <0>{workspaceDisplayName}</0> has been suspended for {daysSinceInactive} days.\":[\"Es scheint, dass Ihr Workspace <0>\",[\"workspaceDisplayName\"],\"</0> seit \",[\"daysSinceInactive\"],\" Tagen gesperrt ist.\"],\"The workspace will be deactivated in {remainingDays} {dayOrDays}, and all its data will be deleted.\":[\"Der Workspace wird in \",[\"remainingDays\"],\" \",[\"dayOrDays\"],\" deaktiviert, und alle Daten werden gelöscht.\"],\"If you wish to continue using Twenty, please update your subscription within the next {remainingDays} {dayOrDays}.\":[\"Wenn Sie Twenty weiterhin nutzen möchten, aktualisieren Sie bitte Ihr Abonnement innerhalb der nächsten \",[\"remainingDays\"],\" \",[\"dayOrDays\"],\".\"],\"Update your subscription\":[\"Aktualisieren Sie Ihr Abonnement\"],\"Validate domain\":[\"Domain validieren\"],\"{senderName} (<0>{senderEmail}</0>): Please validate this domain to allow users with <1>@{domain}</1> email addresses to join your workspace without requiring an invitation.\":[[\"senderName\"],\" (<0>\",[\"senderEmail\"],\"</0>): Bitte validieren Sie diese Domain, damit Benutzer mit <1>@\",[\"domain\"],\"</1> E-Mail-Adressen Ihrem Arbeitsbereich beitreten können, ohne eine Einladung zu benötigen.\"],\"Test email\":[\"Test E-Mail\"],\"Join your team on Twenty\":[\"Treten Sie Ihrem Team auf Twenty bei\"],\"{senderName} (<0>{senderEmail}</0>) has invited you to join a workspace called <1>{workspaceName}</1>.\":[[\"senderName\"],\" (<0>\",[\"senderEmail\"],\"</0>) hat Sie eingeladen, einem Arbeitsbereich namens <1>\",[\"workspaceName\"],\"</1> beizutreten.\"],\"Accept invite\":[\"Einladung akzeptieren\"],\"Confirm your new email address\":[\"Bestätige deine neue E-Mail-Adresse\"],\"Confirm your email address\":[\"Bestätigen Sie Ihre E-Mail-Adresse\"],\"Confirm new email\":[\"Neue E-Mail-Adresse bestätigen\"],\"Verify Email\":[\"E-Mail verifizieren\"],\"Password updated\":[\"Passwort wurde aktualisiert\"],\"This is a confirmation that password for your account ({email}) was successfully changed on {formattedDate}.\":[\"Dies ist eine Bestätigung, dass das Passwort für Ihr Konto (\",[\"email\"],\") am \",[\"formattedDate\"],\" erfolgreich geändert wurde.\"],\"If you did not initiate this change, please contact your workspace owner immediately.\":[\"Wenn Sie diese Änderung nicht veranlasst haben, kontaktieren Sie bitte umgehend den Eigentümer Ihres Workspaces.\"],\"Connect to Twenty\":[\"Mit Twenty verbinden\"],\"Reset your password 🗝\":[\"Setzen Sie Ihr Passwort zurück 🗝\"],\"Set your password 🗝\":[\"Setze dein Passwort 🗝\"],\"Reset\":[\"Zurücksetzen\"],\"Set\":[\"Setzen\"],\"This link is only valid for the next {duration}. If the link does not work, you can use the login verification link directly:\":[\"Dieser Link ist nur für die nächsten \",[\"duration\"],\" gültig. Wenn der Link nicht funktioniert, können Sie den Anmeldebestätigungslink direkt verwenden:\"],\"Deleted Workspace\":[\"Gelöschter Arbeitsbereich\"],\"Your workspace <0>{workspaceDisplayName}</0> has been deleted as your subscription expired {daysSinceInactive} days ago.\":[\"Ihr Workspace <0>\",[\"workspaceDisplayName\"],\"</0> wurde gelöscht, da Ihr Abonnement vor \",[\"daysSinceInactive\"],\" Tagen abgelaufen ist.\"],\"All data in this workspace has been permanently deleted.\":[\"Alle Daten in diesem Workspace wurden dauerhaft gelöscht.\"],\"If you wish to use Twenty again, you can create a new workspace.\":[\"Wenn Sie Twenty erneut nutzen möchten, können Sie einen neuen Workspace erstellen.\"],\"Create a new workspace\":[\"Erstellen Sie einen neuen Arbeitsbereich\"],\"What is Twenty?\":[\"Was ist Twenty?\"],\"It's a CRM, a software to help businesses manage their customer data and relationships efficiently.\":[\"Es ist ein CRM, eine Software, die Unternehmen hilft, ihre Kundendaten und -beziehungen effizient zu verwalten.\"],\"Website\":[\"Website\"],\"Visit Twenty's website\":[\"Besuchen Sie die Twenty-Website\"],\"Github\":[\"GitHub\"],\"Visit Twenty's GitHub repository\":[\"Besuchen Sie das GitHub-Repository von Twenty\"],\"User guide\":[\"Benutzerhandbuch\"],\"Read Twenty's user guide\":[\"Lesen Sie das Benutzerhandbuch von Twenty\"],\"Developers\":[\"Entwickler\"],\"Visit Twenty's developer documentation\":[\"Besuchen Sie die Entwicklerdokumentation von Twenty\"],\"Twenty.com, Public Benefit Corporation\":[\"Twenty.com, Gemeinnützige Aktiengesellschaft\"],\"San Francisco / Paris\":[\"San Francisco / Paris\"]}")as Messages;
@@ -1 +1 @@
/*eslint-disable*/import type{Messages}from"@lingui/core";export const messages=JSON.parse("{\"Suspended Workspace\":[\"Felfüggesztett munkaterület\"],\"Dear {userName},\":[\"Kedves \",[\"userName\"],\",\"],\"Hello,\":[\"Üdvözlöm,\"],\"It appears that your workspace <0>{workspaceDisplayName}</0> has been suspended for {daysSinceInactive} days.\":[\"Úgy tűnik, hogy a munkaterülete <0>\",[\"workspaceDisplayName\"],\"</0> felfüggesztésre került \",[\"daysSinceInactive\"],\" napja.\"],\"The workspace will be deactivated in {remainingDays} {dayOrDays}, and all its data will be deleted.\":[\"A munkaterület \",[\"remainingDays\"],\" \",[\"dayOrDays\"],\" múlva lesz deaktiválva, és minden adat törlésre kerül.\"],\"If you wish to continue using Twenty, please update your subscription within the next {remainingDays} {dayOrDays}.\":[\"Amennyiben szeretné folytatni a Twenty használatát, kérjük frissítse előfizetését a következő \",[\"remainingDays\"],\" \",[\"dayOrDays\"],\" időszakban.\"],\"Update your subscription\":[\"Előfizetés frissítése\"],\"Validate domain\":[\"Domain érvényesítése\"],\"{senderName} (<0>{senderEmail}</0>): Please validate this domain to allow users with <1>@{domain}</1> email addresses to join your workspace without requiring an invitation.\":[[\"senderName\"],\" (<0>\",[\"senderEmail\"],\"</0>): Kérjük, érvényesítse ezt a domaint annak érdekében, hogy az <1>@\",[\"domain\"],\"</1> e-mail címmel rendelkező felhasználók meghívó nélkül csatlakozhassanak a munkaterületéhez.\"],\"Test email\":[\"Teszt e-mail\"],\"Join your team on Twenty\":[\"Csatlakozzon a csapatához a Twentyn\"],\"{senderName} (<0>{senderEmail}</0>) has invited you to join a workspace called <1>{workspaceName}</1>.\":[[\"senderName\"],\" (<0>\",[\"senderEmail\"],\"</0>) meghívta önt, hogy csatlakozzon egy <1>\",[\"workspaceName\"],\"</1> nevű munkaterülethez.\"],\"Accept invite\":[\"Meghívó elfogadása\"],\"Confirm your new email address\":[\"Confirm your new email address\"],\"Confirm your email address\":[\"Erősítse meg email címét\"],\"Confirm new email\":[\"Confirm new email\"],\"Verify Email\":[\"Email ellenőrzése\"],\"Password updated\":[\"Jelszó frissítve\"],\"This is a confirmation that password for your account ({email}) was successfully changed on {formattedDate}.\":[\"Ez egy megerősítés arról, hogy fiókja jelszavát (\",[\"email\"],\") sikeresen megváltoztatták \",[\"formattedDate\"],\" napján.\"],\"If you did not initiate this change, please contact your workspace owner immediately.\":[\"Amennyiben Ön nem kezdeményezte ezt a változtatást, kérjük azonnal lépjen kapcsolatba a munkaterület tulajdonosával.\"],\"Connect to Twenty\":[\"Csatlakozás a Twentyhez\"],\"Reset your password 🗝\":[\"Jelszó visszaállítása 🗝\"],\"Set your password 🗝\":[\"Adja meg a jelszavát 🗝\"],\"Reset\":[\"Visszaállítás\"],\"Set\":[\"Beállítás\"],\"This link is only valid for the next {duration}. If the link does not work, you can use the login verification link directly:\":[\"Ez a hivatkozás csak a következő \",[\"duration\"],\" ideig érvényes. Ha a hivatkozás nem működik, használhatja közvetlenül a belépés ellenőrző hivatkozását:\"],\"Deleted Workspace\":[\"Törölt munkaterület\"],\"Your workspace <0>{workspaceDisplayName}</0> has been deleted as your subscription expired {daysSinceInactive} days ago.\":[\"Munkaterülete <0>\",[\"workspaceDisplayName\"],\"</0> törlésre került, mivel előfizetése \",[\"daysSinceInactive\"],\" nappal ezelőtt lejárt.\"],\"All data in this workspace has been permanently deleted.\":[\"A munkaterület minden adata végérvényesen törlésre került.\"],\"If you wish to use Twenty again, you can create a new workspace.\":[\"Ha újra szeretné használni a Twenty-t, létrehozhat egy új munkaterületet.\"],\"Create a new workspace\":[\"Hozzon létre új munkaterületet\"],\"What is Twenty?\":[\"Mi az a Twenty?\"],\"It's a CRM, a software to help businesses manage their customer data and relationships efficiently.\":[\"Ez egy CRM, egy szoftver, amely segíti a vállalkozásokat az ügyféladataik és kapcsolataik hatékony kezelésében.\"],\"Website\":[\"Weboldal\"],\"Visit Twenty's website\":[\"Látogasson el a Twenty weboldalára\"],\"Github\":[\"Github\"],\"Visit Twenty's GitHub repository\":[\"Látogasson el a Twenty GitHub tárházához\"],\"User guide\":[\"Felhasználói útmutató\"],\"Read Twenty's user guide\":[\"Olvassa el a Twenty felhasználói útmutatóját\"],\"Developers\":[\"Fejlesztők\"],\"Visit Twenty's developer documentation\":[\"Látogasson el a Twenty fejlesztői dokumentációjához\"],\"Twenty.com, Public Benefit Corporation\":[\"Twenty.com, Közhasznú Vállalat\"],\"San Francisco / Paris\":[\"San Francisco / Párizs\"]}")as Messages;
/*eslint-disable*/import type{Messages}from"@lingui/core";export const messages=JSON.parse("{\"Suspended Workspace\":[\"Felfüggesztett munkaterület\"],\"Dear {userName},\":[\"Kedves \",[\"userName\"],\",\"],\"Hello,\":[\"Üdvözlöm,\"],\"It appears that your workspace <0>{workspaceDisplayName}</0> has been suspended for {daysSinceInactive} days.\":[\"Úgy tűnik, hogy a munkaterülete <0>\",[\"workspaceDisplayName\"],\"</0> felfüggesztésre került \",[\"daysSinceInactive\"],\" napja.\"],\"The workspace will be deactivated in {remainingDays} {dayOrDays}, and all its data will be deleted.\":[\"A munkaterület \",[\"remainingDays\"],\" \",[\"dayOrDays\"],\" múlva lesz deaktiválva, és minden adat törlésre kerül.\"],\"If you wish to continue using Twenty, please update your subscription within the next {remainingDays} {dayOrDays}.\":[\"Amennyiben szeretné folytatni a Twenty használatát, kérjük frissítse előfizetését a következő \",[\"remainingDays\"],\" \",[\"dayOrDays\"],\" időszakban.\"],\"Update your subscription\":[\"Előfizetés frissítése\"],\"Validate domain\":[\"Domain érvényesítése\"],\"{senderName} (<0>{senderEmail}</0>): Please validate this domain to allow users with <1>@{domain}</1> email addresses to join your workspace without requiring an invitation.\":[[\"senderName\"],\" (<0>\",[\"senderEmail\"],\"</0>): Kérjük, érvényesítse ezt a domaint annak érdekében, hogy az <1>@\",[\"domain\"],\"</1> e-mail címmel rendelkező felhasználók meghívó nélkül csatlakozhassanak a munkaterületéhez.\"],\"Test email\":[\"Teszt e-mail\"],\"Join your team on Twenty\":[\"Csatlakozzon a csapatához a Twentyn\"],\"{senderName} (<0>{senderEmail}</0>) has invited you to join a workspace called <1>{workspaceName}</1>.\":[[\"senderName\"],\" (<0>\",[\"senderEmail\"],\"</0>) meghívta önt, hogy csatlakozzon egy <1>\",[\"workspaceName\"],\"</1> nevű munkaterülethez.\"],\"Accept invite\":[\"Meghívó elfogadása\"],\"Confirm your new email address\":[\"Erősítsd meg az új e-mail címed\"],\"Confirm your email address\":[\"Erősítse meg email címét\"],\"Confirm new email\":[\"Új e-mail megerősítése\"],\"Verify Email\":[\"Email ellenőrzése\"],\"Password updated\":[\"Jelszó frissítve\"],\"This is a confirmation that password for your account ({email}) was successfully changed on {formattedDate}.\":[\"Ez egy megerősítés arról, hogy fiókja jelszavát (\",[\"email\"],\") sikeresen megváltoztatták \",[\"formattedDate\"],\" napján.\"],\"If you did not initiate this change, please contact your workspace owner immediately.\":[\"Amennyiben Ön nem kezdeményezte ezt a változtatást, kérjük azonnal lépjen kapcsolatba a munkaterület tulajdonosával.\"],\"Connect to Twenty\":[\"Csatlakozás a Twentyhez\"],\"Reset your password 🗝\":[\"Jelszó visszaállítása 🗝\"],\"Set your password 🗝\":[\"Adja meg a jelszavát 🗝\"],\"Reset\":[\"Visszaállítás\"],\"Set\":[\"Beállítás\"],\"This link is only valid for the next {duration}. If the link does not work, you can use the login verification link directly:\":[\"Ez a hivatkozás csak a következő \",[\"duration\"],\" ideig érvényes. Ha a hivatkozás nem működik, használhatja közvetlenül a belépés ellenőrző hivatkozását:\"],\"Deleted Workspace\":[\"Törölt munkaterület\"],\"Your workspace <0>{workspaceDisplayName}</0> has been deleted as your subscription expired {daysSinceInactive} days ago.\":[\"Munkaterülete <0>\",[\"workspaceDisplayName\"],\"</0> törlésre került, mivel előfizetése \",[\"daysSinceInactive\"],\" nappal ezelőtt lejárt.\"],\"All data in this workspace has been permanently deleted.\":[\"A munkaterület minden adata végérvényesen törlésre került.\"],\"If you wish to use Twenty again, you can create a new workspace.\":[\"Ha újra szeretné használni a Twenty-t, létrehozhat egy új munkaterületet.\"],\"Create a new workspace\":[\"Hozzon létre új munkaterületet\"],\"What is Twenty?\":[\"Mi az a Twenty?\"],\"It's a CRM, a software to help businesses manage their customer data and relationships efficiently.\":[\"Ez egy CRM, egy szoftver, amely segíti a vállalkozásokat az ügyféladataik és kapcsolataik hatékony kezelésében.\"],\"Website\":[\"Weboldal\"],\"Visit Twenty's website\":[\"Látogasson el a Twenty weboldalára\"],\"Github\":[\"Github\"],\"Visit Twenty's GitHub repository\":[\"Látogasson el a Twenty GitHub tárházához\"],\"User guide\":[\"Felhasználói útmutató\"],\"Read Twenty's user guide\":[\"Olvassa el a Twenty felhasználói útmutatóját\"],\"Developers\":[\"Fejlesztők\"],\"Visit Twenty's developer documentation\":[\"Látogasson el a Twenty fejlesztői dokumentációjához\"],\"Twenty.com, Public Benefit Corporation\":[\"Twenty.com, Közhasznú Vállalat\"],\"San Francisco / Paris\":[\"San Francisco / Párizs\"]}")as Messages;
+2 -2
View File
@@ -91,7 +91,7 @@ msgstr "Meghívó elfogadása"
#. js-lingui-explicit-id
#: src/emails/send-email-verification-link.email.tsx
msgid "Confirm your new email address"
msgstr ""
msgstr "Erősítsd meg az új e-mail címed"
#. js-lingui-explicit-id
#: src/emails/send-email-verification-link.email.tsx
@@ -101,7 +101,7 @@ msgstr "Erősítse meg email címét"
#. js-lingui-explicit-id
#: src/emails/send-email-verification-link.email.tsx
msgid "Confirm new email"
msgstr ""
msgstr "Új e-mail megerősítése"
#. js-lingui-explicit-id
#: src/emails/send-email-verification-link.email.tsx
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
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 it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -535,20 +535,4 @@ describe('isRecordMatchingFilter', () => {
).toBe(false);
});
});
describe('Missing field metadata', () => {
it('should return true when filter references a field not in object metadata', () => {
const filter: RecordGqlOperationFilter = {
nonExistentFieldId: { eq: 'some-value' },
};
expect(
isRecordMatchingFilter({
record: companiesMock[0],
filter,
objectMetadataItem: companyMockObjectMetadataItem,
}),
).toBe(true);
});
});
});
@@ -215,10 +215,12 @@ export const isRecordMatchingFilter = ({
);
if (!isDefined(objectMetadataField)) {
// Stale cached queries can reference fields that no longer exist
// in the metadata (e.g. deleted or deactivated custom fields).
// Skip the filter condition to avoid crashing optimistic updates.
return true;
throw new Error(
'Field metadata item "' +
filterKey +
'" not found for object metadata item ' +
objectMetadataItem.nameSingular,
);
}
switch (objectMetadataField.type) {
@@ -35,7 +35,7 @@ export const useUpsertObjectPermission = ({ roleId }: { roleId: string }) => {
newPermissions.canReadObjectRecords = value;
}
if (permissionKey === 'canReadObjectRecords' && !value) {
if (permissionKey === 'canReadObjectRecords' && value === false) {
newPermissions.canUpdateObjectRecords = false;
newPermissions.canSoftDeleteObjectRecords = false;
newPermissions.canDestroyObjectRecords = false;
@@ -111,6 +111,7 @@ const StyledItem = styled.button<StyledItemProps>`
height: ${themeCssVariables.spacing[7]};
margin-top: ${({ indentationLevel }) =>
indentationLevel === 2 ? '2px' : '0'};
min-width: 0;
padding-bottom: ${themeCssVariables.spacing[1]};
padding-left: ${themeCssVariables.spacing[1]};
padding-right: ${({ hasRightOptions }) =>
@@ -233,9 +233,9 @@ export const SettingsDevelopersApiKeyDetail = () => {
return (
<>
{apiKey?.name && (
{isDefined(apiKey) && (
<SubMenuTopBarContainer
title={apiKey?.name}
title={apiKey.name || t`Unnamed API Key`}
links={[
{
children: t`Workspace`,
@@ -245,7 +245,7 @@ export const SettingsDevelopersApiKeyDetail = () => {
children: t`APIs & Webhooks`,
href: getSettingsPath(SettingsPath.ApiWebhooks),
},
{ children: apiKey?.name },
{ children: apiKey.name || t`Unnamed API Key` },
]}
>
<SettingsPageContainer>
@@ -75,6 +75,10 @@ export const SettingsDevelopersApiKeysNew = () => {
formValues.expirationDate ?? 30,
).toISOString();
if (!formValues.name) {
return;
}
const roleIdToUse = formValues.roleId;
if (!roleIdToUse) {
@@ -137,7 +141,7 @@ export const SettingsDevelopersApiKeysNew = () => {
]}
actionButton={
<SaveAndCancelButtons
isSaveDisabled={!isDefined(canSave)}
isSaveDisabled={!canSave}
onCancel={() => {
navigateSettings(SettingsPath.ApiWebhooks);
}}
@@ -0,0 +1,225 @@
import { type NextFunction, type Request, type Response } from 'express';
import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { workspaceAuthContextStorage } from 'src/engine/core-modules/auth/storage/workspace-auth-context.storage';
import { WorkspaceAuthContextMiddleware } from '../workspace-auth-context.middleware';
const mockWorkspace = {
id: 'workspace-id',
displayName: 'Test Workspace',
} as Request['workspace'];
const mockUser = {
id: 'user-id',
email: 'test@example.com',
firstName: 'Test',
lastName: 'User',
} as Request['user'];
const mockApplication = {
id: 'application-id',
name: 'Test App',
defaultRoleId: 'app-role-id',
} as Request['application'];
const mockApiKey = {
id: 'api-key-id',
name: 'Test API Key',
} as Request['apiKey'];
const mockWorkspaceMember = {
id: 'workspace-member-id',
name: { firstName: 'Test', lastName: 'User' },
} as Request['workspaceMember'];
describe('WorkspaceAuthContextMiddleware', () => {
let middleware: WorkspaceAuthContextMiddleware;
let mockResponse: Response;
let mockNext: NextFunction;
beforeEach(() => {
middleware = new WorkspaceAuthContextMiddleware();
mockResponse = {} as Response;
mockNext = jest.fn();
});
const buildRequest = (overrides: Partial<Request> = {}): Request =>
({
workspace: mockWorkspace,
...overrides,
}) as unknown as Request;
it('should call next without auth context when workspace is not defined', () => {
const req = buildRequest({ workspace: undefined });
middleware.use(req, mockResponse, mockNext);
expect(mockNext).toHaveBeenCalled();
expect(workspaceAuthContextStorage.getStore()).toBeUndefined();
});
it('should create an apiKey auth context when apiKey is present', () => {
const req = buildRequest({ apiKey: mockApiKey });
let capturedContext: unknown;
(mockNext as jest.Mock).mockImplementation(() => {
capturedContext = workspaceAuthContextStorage.getStore();
});
middleware.use(req, mockResponse, mockNext);
expect(capturedContext).toEqual(
expect.objectContaining({ type: 'apiKey', apiKey: mockApiKey }),
);
});
it('should create a user auth context when both application and user are present', () => {
const req = buildRequest({
application: mockApplication,
user: mockUser,
userWorkspaceId: 'user-workspace-id',
workspaceMemberId: 'workspace-member-id',
workspaceMember: mockWorkspaceMember,
});
let capturedContext: unknown;
(mockNext as jest.Mock).mockImplementation(() => {
capturedContext = workspaceAuthContextStorage.getStore();
});
middleware.use(req, mockResponse, mockNext);
expect(capturedContext).toEqual(
expect.objectContaining({
type: 'user',
user: mockUser,
userWorkspaceId: 'user-workspace-id',
workspaceMemberId: 'workspace-member-id',
workspaceMember: mockWorkspaceMember,
}),
);
});
it('should create an application auth context when application is present without user', () => {
const req = buildRequest({ application: mockApplication });
let capturedContext: unknown;
(mockNext as jest.Mock).mockImplementation(() => {
capturedContext = workspaceAuthContextStorage.getStore();
});
middleware.use(req, mockResponse, mockNext);
expect(capturedContext).toEqual(
expect.objectContaining({
type: 'application',
application: mockApplication,
}),
);
});
it('should fall back to application auth context when application and user are present but workspaceMember is missing', () => {
const req = buildRequest({
application: mockApplication,
user: mockUser,
userWorkspaceId: 'user-workspace-id',
});
let capturedContext: unknown;
(mockNext as jest.Mock).mockImplementation(() => {
capturedContext = workspaceAuthContextStorage.getStore();
});
middleware.use(req, mockResponse, mockNext);
expect(capturedContext).toEqual(
expect.objectContaining({
type: 'application',
application: mockApplication,
}),
);
});
it('should create a user auth context when user is present without application', () => {
const req = buildRequest({
user: mockUser,
userWorkspaceId: 'user-workspace-id',
workspaceMemberId: 'workspace-member-id',
workspaceMember: mockWorkspaceMember,
});
let capturedContext: unknown;
(mockNext as jest.Mock).mockImplementation(() => {
capturedContext = workspaceAuthContextStorage.getStore();
});
middleware.use(req, mockResponse, mockNext);
expect(capturedContext).toEqual(
expect.objectContaining({
type: 'user',
user: mockUser,
userWorkspaceId: 'user-workspace-id',
}),
);
});
it('should create a pendingActivationUser auth context when user and userWorkspaceId are present without workspaceMember', () => {
const req = buildRequest({
user: mockUser,
userWorkspaceId: 'user-workspace-id',
});
let capturedContext: unknown;
(mockNext as jest.Mock).mockImplementation(() => {
capturedContext = workspaceAuthContextStorage.getStore();
});
middleware.use(req, mockResponse, mockNext);
expect(capturedContext).toEqual(
expect.objectContaining({
type: 'pendingActivationUser',
user: mockUser,
userWorkspaceId: 'user-workspace-id',
}),
);
});
it('should throw AuthException when workspace is present but no auth mechanism is found', () => {
const req = buildRequest();
expect(() => middleware.use(req, mockResponse, mockNext)).toThrow(
new AuthException(
'No authentication context found',
AuthExceptionCode.UNAUTHENTICATED,
),
);
});
it('should prioritize apiKey over application and user', () => {
const req = buildRequest({
apiKey: mockApiKey,
application: mockApplication,
user: mockUser,
userWorkspaceId: 'user-workspace-id',
workspaceMemberId: 'workspace-member-id',
workspaceMember: mockWorkspaceMember,
});
let capturedContext: unknown;
(mockNext as jest.Mock).mockImplementation(() => {
capturedContext = workspaceAuthContextStorage.getStore();
});
middleware.use(req, mockResponse, mockNext);
expect(capturedContext).toEqual(
expect.objectContaining({ type: 'apiKey' }),
);
});
});
@@ -38,13 +38,6 @@ export class WorkspaceAuthContextMiddleware implements NestMiddleware {
});
}
if (isDefined(req.application)) {
return buildApplicationAuthContext({
workspace: req.workspace!,
application: req.application,
});
}
if (
isDefined(req.userWorkspaceId) &&
isDefined(req.workspaceMemberId) &&
@@ -60,6 +53,13 @@ export class WorkspaceAuthContextMiddleware implements NestMiddleware {
});
}
if (isDefined(req.application)) {
return buildApplicationAuthContext({
workspace: req.workspace!,
application: req.application,
});
}
if (isDefined(req.userWorkspaceId) && isDefined(req.user)) {
return buildPendingActivationUserAuthContext({
workspace: req.workspace!,
@@ -401,6 +401,20 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
context.user = userContext.user;
context.userWorkspace = userContext.userWorkspace;
context.userWorkspaceId = userContext.userWorkspace.id;
const { flatWorkspaceMemberMaps } =
await this.workspaceCacheService.getOrRecompute(workspace.id, [
'flatWorkspaceMemberMaps',
]);
const workspaceMemberId =
flatWorkspaceMemberMaps.idByUserId[userContext.user.id];
if (isDefined(workspaceMemberId)) {
context.workspaceMemberId = workspaceMemberId;
context.workspaceMember =
flatWorkspaceMemberMaps.byId[workspaceMemberId];
}
}
}
@@ -1,8 +1,6 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { FileCorePictureResolver } from 'src/engine/core-modules/file/file-core-picture/resolvers/file-core-picture.resolver';
@@ -16,10 +14,9 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
@Module({
imports: [
JwtModule,
TypeOrmModule.forFeature([FileEntity, WorkspaceEntity, ApplicationEntity]),
TypeOrmModule.forFeature([FileEntity, WorkspaceEntity]),
PermissionsModule,
FileStorageModule,
ApplicationModule,
FileUrlModule,
SecureHttpClientModule,
],
@@ -11,7 +11,10 @@ import { isDefined } from 'twenty-shared/utils';
import { Like, type QueryRunner, Repository } from 'typeorm';
import { v4 } from 'uuid';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import {
ApplicationException,
ApplicationExceptionCode,
} from 'src/engine/core-modules/application/application.exception';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { FileWithSignedUrlDTO } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
@@ -29,7 +32,6 @@ export class FileCorePictureService {
constructor(
private readonly fileStorageService: FileStorageService,
private readonly applicationService: ApplicationService,
@InjectRepository(WorkspaceEntity)
private readonly workspaceRepository: Repository<WorkspaceEntity>,
@InjectRepository(FileEntity)
@@ -38,6 +40,25 @@ export class FileCorePictureService {
private readonly secureHttpClientService: SecureHttpClientService,
) {}
private async findCustomApplicationUniversalIdentifier(
workspaceId: string,
): Promise<string> {
const workspace = await this.workspaceRepository.findOne({
where: { id: workspaceId },
select: ['workspaceCustomApplicationId'],
withDeleted: true,
});
if (!isDefined(workspace)) {
throw new ApplicationException(
`Could not find workspace ${workspaceId}`,
ApplicationExceptionCode.APPLICATION_NOT_FOUND,
);
}
return workspace.workspaceCustomApplicationId;
}
private async uploadCorePicture({
file,
filename,
@@ -59,11 +80,7 @@ export class FileCorePictureService {
const universalIdentifier =
applicationUniversalIdentifier ??
(
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
)
).workspaceCustomFlatApplication.universalIdentifier;
(await this.findCustomApplicationUniversalIdentifier(workspaceId));
const savedFile = await this.fileStorageService.writeFile({
sourceFile: sanitizedFile,
@@ -169,17 +186,12 @@ export class FileCorePictureService {
},
});
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{
workspaceId,
},
);
const customApplicationUniversalIdentifier =
await this.findCustomApplicationUniversalIdentifier(workspaceId);
await this.fileStorageService.delete({
workspaceId,
applicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
applicationUniversalIdentifier: customApplicationUniversalIdentifier,
fileFolder: FileFolder.CorePicture,
resourcePath: removeFileFolderFromFileEntityPath(file.path),
});
@@ -286,16 +298,12 @@ export class FileCorePictureService {
},
});
const { workspaceCustomFlatApplication: sourceApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{
workspaceId: sourceWorkspaceId,
},
);
const sourceApplicationUniversalIdentifier =
await this.findCustomApplicationUniversalIdentifier(sourceWorkspaceId);
const fileStream = await this.fileStorageService.readFile({
workspaceId: sourceWorkspaceId,
applicationUniversalIdentifier: sourceApplication.universalIdentifier,
applicationUniversalIdentifier: sourceApplicationUniversalIdentifier,
fileFolder: FileFolder.CorePicture,
resourcePath: removeFileFolderFromFileEntityPath(sourceFile.path),
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
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

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