OAuth Client — Unified ApplicationRegistration, OAuth server, and frontend (#18267)

## Summary

Consolidates three separate PRs (#18260, #18261, #18262) into a single
unified branch with all review feedback addressed:

### New features
- **ApplicationRegistration entity** — server-level registration for
OAuth apps with encrypted server variables
- **OAuth 2.0 server** — authorization code, client credentials, refresh
token grants with PKCE support
- **OAuth discovery endpoint** —
`.well-known/oauth-authorization-server` metadata
- **Frontend UI** — app registration details page with credential
management, redirect URI editing, and server variable configuration
- **CLI integration** — `twenty dev` auto-registers apps and stores
OAuth credentials locally
- **Authorize consent screen** — OAuth consent page at `/authorize`
showing requested scopes

### Review feedback addressed

**Renames (PR #18260):**
- `appRegistration` → `applicationRegistration` (entity, tables, files,
imports, GraphQL types)
- `appRegistrationVariable` → `applicationRegistrationVariable`
- `clientId` → `oAuthClientId`, `clientSecretHash` →
`oAuthClientSecretHash`, `redirectUris` → `oAuthRedirectUris`, `scopes`
→ `oAuthScopes`

**Security fixes (PR #18261):**
- Fixed redirect URI validation bypass when `oAuthRedirectUris` is an
empty array
- Fixed workspace isolation in `clientCredentialsGrant` — now uses
`find()` with explicit handling for multiple installations
- Added error logging in refresh token `catch` block instead of silently
swallowing

**Code quality (PR #18262):**
- Split `VersionDistributionEntry` into its own file (one export per
file)
- Split GraphQL queries and mutations into individual files with a
shared fragment
- Removed unused `OAuth` entry from `AuthProviderEnum`
- Added loading state to `handleRotateSecret`
- Removed 27 narration-style comments from test files
- Added proper guards (`PublicEndpointGuard`, `NoPermissionGuard`) to
controllers and resolvers

## Test plan

- [ ] Verify `twenty dev` registers an app and stores OAuth credentials
- [ ] Test OAuth authorization code flow end-to-end (authorize → token →
API call)
- [ ] Test client credentials grant
- [ ] Verify redirect URI validation rejects requests when no URIs are
registered
- [ ] Verify app registration detail page renders correctly
- [ ] Test secret rotation with loading state
- [ ] Verify server variable editing and saving
- [ ] Run `npx nx database:reset twenty-server` to validate migration

Closes #18260, #18261, #18262


Made with [Cursor](https://cursor.com)

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
This commit is contained in:
Félix Malfait
2026-02-28 14:07:49 +01:00
committed by GitHub
parent 9fe2a07c55
commit 012d819557
242 changed files with 5717 additions and 1100 deletions
+283
View File
@@ -0,0 +1,283 @@
# npm-Based App Distribution for Twenty
*Technical Design Document -- February 2026*
## Overview
Add npm registry support for distributing Twenty apps (public and private), with per-AppRegistration registry overrides, direct tarball upload as escape hatch, and version upgrade detection.
## Assumptions
- The marketplace install flow (currently a TODO in the resolver and frontend) will be implemented separately. This plan provides the infrastructure that install flow will call into.
- The existing `app:dev` flow (individual file uploads via CLI) remains unchanged.
- The existing GitHub-based marketplace discovery remains as a curated fallback.
## Architecture
```
Developer
/ | \
npm publish | twenty app:push
/ | \
npmjs.com Private Reg Server REST Upload
| | |
v v v
[Discovery Layer] [Direct Upload]
npm search API |
GitHub curated list |
| |
v |
MarketplaceService |
(sourcePackage in DTO) |
| |
v v
AppPackageResolverService <--------+
.npmrc generation
yarn add / tarball extract
|
v
ApplicationSyncService (existing)
WorkspaceMigrationRunnerService
|
v
AppRegistration + Application entities
```
## Phase 1: Entity and Config Changes
### 1a. Extend AppRegistrationEntity
Add four columns to `application-registration.entity.ts`:
- **`sourcePackage`** (text, nullable) -- npm package name, e.g. `"twenty-app-fireflies"` or `"@myorg/twenty-app-crm"`. Null for tarball-only or OAuth-only apps.
- **`tarballFileId`** (uuid, nullable) -- FK to a FileEntity storing a directly-uploaded `.tar.gz`. Null when the app comes from npm.
- **`registryUrl`** (text, nullable) -- per-registration npm registry override. Null means "use the server default `APP_REGISTRY_URL`." This is how a single server can pull public apps from npmjs.com while pulling `@mycompany/*` apps from GitHub Packages.
- **`latestAvailableVersion`** (text, nullable) -- cached latest version from the registry, updated periodically. Compared against `Application.version` to surface upgrade availability.
**Source resolution priority:**
1. `sourcePackage` is set → resolve from npm via `yarn add`
2. `tarballFileId` is set → extract from file storage
3. Neither → OAuth-only app, no server-side code
### 1b. Extend ApplicationEntity.sourceType
Widen the `sourceType` union from `'local'` to `'local' | 'npm' | 'tarball'`:
- `'local'` -- existing behavior (CLI `app:dev`, individual file uploads, workspace-custom)
- `'npm'` -- installed from an npm registry via `yarn add`
- `'tarball'` -- installed from a directly-uploaded tarball
This lets the system distinguish how an app was installed, which matters for upgrade logic (npm apps can check the registry for newer versions; tarball apps cannot).
### 1c. Add server-wide config variables
Add a new `APP_REGISTRY_CONFIG` group to ConfigVariablesGroup:
- **`APP_REGISTRY_URL`** (string, default `https://registry.npmjs.org`) -- default npm registry URL
- **`APP_REGISTRY_TOKEN`** (string, optional, sensitive) -- auth token for the default registry
### 1d. Generate migration
TypeORM migration adding `sourcePackage`, `tarballFileId`, `registryUrl`, `latestAvailableVersion` to `core.applicationRegistration`.
## Phase 2: App Package Resolver Service
### 2a. Create AppPackageResolverService
New service with core method:
```
resolvePackage(appRegistration, options?: { targetVersion? }) → ResolvedPackage | null
```
Returns `{ manifestPath, packageJsonPath, filesDir }` or null for OAuth-only apps.
**Resolution logic:**
```
if sourcePackage:
1. Determine registry: appRegistration.registryUrl ?? APP_REGISTRY_URL
2. Determine auth token for the resolved registry
3. Generate temporary .npmrc in an isolated working directory
4. Run: yarn add <sourcePackage>@<targetVersion ?? latest>
5. Read manifest from node_modules/<sourcePackage>/.twenty/output/manifest.json
6. Return paths
if tarballFileId:
1. Download tarball from FileStorageService
2. Extract to temporary directory
3. Read manifest from extracted files
4. Return paths
else:
return null (OAuth-only)
```
### 2b. Isolated working directories
Each resolution runs in a temporary directory under `{os.tmpdir()}/twenty-app-resolver/{uuid}/`. This avoids contaminating the server's own `node_modules` and isolates apps from each other. Cleaned up after files are copied to storage.
### 2c. .npmrc generation
For scoped packages (`@scope/twenty-app-*`):
```
@scope:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=TOKEN
```
For unscoped packages with a non-default registry:
```
registry=https://my-verdaccio.internal:4873
//my-verdaccio.internal:4873/:_authToken=TOKEN
```
### 2d. Post-resolution file transfer
After resolving, copies files into the app's storage path using the existing FileStorageService layout:
```
{workspaceId}/{applicationUniversalIdentifier}/
built-logic-function/...
built-front-component/...
dependencies/package.json
dependencies/yarn.lock
public-asset/...
source/...
```
This reuses the same FileFolder enum paths that `app:dev` uses, so downstream ApplicationSyncService works unchanged.
## Phase 3: Marketplace Discovery via npm
### 3a. Update MarketplaceService
Add npm-based discovery alongside the existing GitHub path:
- Query npm search API: `GET {registryUrl}/-/v1/search?text=keywords:twenty-app&size=250`
- Map each result to MarketplaceAppDTO using package.json metadata
**Merge strategy:**
1. Fetch from npm search API (apps with `keywords: ["twenty-app"]`)
2. Fetch from GitHub (existing curated list)
3. Merge by `universalIdentifier` -- GitHub entries override npm entries (allowing curation)
4. Cache merged result with existing 1-hour TTL
### 3b. Add sourcePackage to MarketplaceAppDTO
The DTO needs a `sourcePackage: string | null` field so the install flow knows which npm package to resolve. For npm-discovered apps, this is the package name. For GitHub-only apps, this is null.
## Phase 4: Version Upgrade Support
### 4a. Create AppUpgradeService
**Periodic version check (npm-sourced apps only):**
- Fetches `{registryUrl}/{sourcePackage}/latest` from the npm registry
- Stores result in `AppRegistration.latestAvailableVersion`
- Frontend compares against `Application.version` to show "Update available"
**Upgrade trigger:**
1. Resolve the new version via AppPackageResolverService
2. Sync via existing ApplicationSyncService (triggers workspace migration for schema changes)
3. Update Application.version
**Rollback strategy:** If sync fails (e.g., migration validation error), re-resolve the previous version and re-sync. This is possible because npm retains all published versions.
### 4b. Version check scheduling
Lightweight cron or check-on-access pattern. Iterates over AppRegistrations where `sourcePackage IS NOT NULL` and calls `checkForUpdates()`. Frequency: once per hour, matching the existing marketplace cache TTL.
## Phase 5: SDK CLI Commands
### 5a. Finalize `twenty app:build`
Ensure `.twenty/output/` is npm-publishable. The build step generates a `package.json` in the output directory:
```json
{
"name": "twenty-app-fireflies",
"version": "1.2.0",
"keywords": ["twenty-app"],
"twenty": {
"universalIdentifier": "a4df0c0f-c65e-44e5-8436-24814182d4ac"
},
"files": ["manifest.json", "built-logic-function", "built-front-component", "public-asset"]
}
```
The developer then publishes with standard `npm publish` -- no custom command needed.
### 5b. `twenty app:pack` (new command)
```
twenty app:pack [appPath]
```
- Runs `app:build` if `.twenty/output/` doesn't exist or is stale
- Uses existing TarballService to create `{name}-{version}.tar.gz`
- Outputs the file path for manual distribution
### 5c. `twenty app:push` (new command)
```
twenty app:push [appPath] --server <url> --token <token>
```
- Runs `app:pack` to produce the tarball
- Reads universalIdentifier from manifest to find or create the AppRegistration
- Uploads via `POST /api/app-registrations/upload-tarball`
- Reports success with the registration ID
- Reuses `twenty auth:login` credentials if `--server` is not specified
## Phase 6: Server Tarball Upload Endpoint
### 6a. REST controller
```
POST /api/app-registrations/upload-tarball
Content-Type: multipart/form-data
Body: tarball file + optional universalIdentifier
```
**Validation:**
- Max file size: 50MB
- Must be a valid `.tar.gz`
- Extracted contents must contain `manifest.json` with a valid `universalIdentifier`
- The `universalIdentifier` must not conflict with an existing registration owned by a different user
**Flow:**
1. Extract tarball to temp directory
2. Validate manifest structure
3. Find or create AppRegistration by universalIdentifier
4. Store tarball in FileStorageService under `FileFolder.AppTarball`
5. Set `tarballFileId` on the AppRegistration
6. Return the AppRegistration entity
### 6b. Add FileFolder.AppTarball
New enum value `AppTarball = 'app-tarball'` in FileFolder.
## Key Design Decisions
| Decision | Rationale |
|---|---|
| Per-AppRegistration registry override | `registryUrl` on the entity allows mixing registries. Public apps from npmjs.com, private from GitHub Packages/Verdaccio. Server-wide `APP_REGISTRY_URL` is the fallback. |
| npm publish is standard | No custom publish infra. Free versioning, README, `npm audit`, download stats, proven auth model. |
| Tarball as escape hatch | Air-gapped environments, CI pipelines, one-off installs. Cannot auto-upgrade. |
| sourceType distinction | `'npm' \| 'tarball' \| 'local'` lets the system know which upgrade path is available. Only npm apps can check for newer versions. |
| Backward compatible | `app:dev` flow unchanged. GitHub marketplace unchanged. All new fields nullable. |
| Upgrade rollback | Re-resolve previous version from npm on failure. Safe because npm never deletes published versions. |
## Edge Cases
- **npm unreachable**: Timeout after 30s, throw clear error. App remains at current installed version.
- **Package name conflicts**: The `universalIdentifier` in the `twenty` field of `package.json` is the source of truth, not the npm package name. Two packages with the same universalIdentifier conflict at the AppRegistration level (unique index).
- **Scoped vs unscoped packages**: Both work. Scoped packages naturally route to a private registry via `.npmrc` scope mapping.
- **Multiple workspaces, same server**: AppRegistration is server-level (core schema). Application is workspace-level. One AppRegistration can be installed in multiple workspaces at different versions.
Binary file not shown.
@@ -5,7 +5,6 @@ import * as fs from 'fs-extra';
import { tmpdir } from 'os';
import { join } from 'path';
// Mock fs-extra's copy function to skip copying base template (not available during tests)
jest.mock('fs-extra', () => {
const actual = jest.requireActual('fs-extra');
return {
@@ -41,7 +40,6 @@ describe('copyBaseApplicationProject', () => {
let testAppDirectory: string;
beforeEach(async () => {
// Create a unique temp directory for each test
testAppDirectory = join(
tmpdir(),
`test-twenty-app-${Date.now()}-${Math.random().toString(36).slice(2)}`,
@@ -51,7 +49,6 @@ describe('copyBaseApplicationProject', () => {
});
afterEach(async () => {
// Clean up temp directory after each test
if (testAppDirectory && (await fs.pathExists(testAppDirectory))) {
await fs.remove(testAppDirectory);
}
@@ -66,15 +63,12 @@ describe('copyBaseApplicationProject', () => {
exampleOptions: ALL_EXAMPLES,
});
// Verify src/ folder exists
const srcAppPath = join(testAppDirectory, 'src');
expect(await fs.pathExists(srcAppPath)).toBe(true);
// Verify application-config.ts exists in src/
const appConfigPath = join(srcAppPath, APPLICATION_FILE_NAME);
expect(await fs.pathExists(appConfigPath)).toBe(true);
// Verify default-role.ts exists in src/
const roleConfigPath = join(srcAppPath, 'roles', DEFAULT_ROLE_FILE_NAME);
expect(await fs.pathExists(roleConfigPath)).toBe(true);
});
@@ -143,27 +137,22 @@ describe('copyBaseApplicationProject', () => {
const appConfigPath = join(testAppDirectory, 'src', APPLICATION_FILE_NAME);
const appConfigContent = await fs.readFile(appConfigPath, 'utf8');
// Verify it uses defineApplication
expect(appConfigContent).toContain(
"import { defineApplication } from 'twenty-sdk'",
);
expect(appConfigContent).toContain('export default defineApplication({');
// Verify it imports the role identifier
expect(appConfigContent).toContain(
"import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role'",
);
// Verify display name and description
expect(appConfigContent).toContain("displayName: 'My Test App'");
expect(appConfigContent).toContain("description: 'A test application'");
// Verify it has a universalIdentifier (UUID format)
expect(appConfigContent).toMatch(
/universalIdentifier: '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'/,
);
// Verify it references the role
expect(appConfigContent).toContain(
'defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER',
);
@@ -186,29 +175,24 @@ describe('copyBaseApplicationProject', () => {
);
const roleConfigContent = await fs.readFile(roleConfigPath, 'utf8');
// Verify it uses defineRole
expect(roleConfigContent).toContain(
"import { defineRole } from 'twenty-sdk'",
);
expect(roleConfigContent).toContain('export default defineRole({');
// Verify it exports the universal identifier constant
expect(roleConfigContent).toContain(
'export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER',
);
// Verify role label includes app name
expect(roleConfigContent).toContain(
"label: 'My Test App default function role'",
);
// Verify default permissions
expect(roleConfigContent).toContain('canReadAllObjectRecords: true');
expect(roleConfigContent).toContain('canUpdateAllObjectRecords: true');
expect(roleConfigContent).toContain('canSoftDeleteAllObjectRecords: true');
expect(roleConfigContent).toContain('canDestroyAllObjectRecords: false');
// Verify it has a universalIdentifier (UUID format)
expect(roleConfigContent).toMatch(
/universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER/,
);
@@ -223,7 +207,6 @@ describe('copyBaseApplicationProject', () => {
exampleOptions: ALL_EXAMPLES,
});
// Verify fs.copy was called with correct destination
expect(fs.copy).toHaveBeenCalledTimes(1);
expect(fs.copy).toHaveBeenCalledWith(
expect.stringContaining('base-application'),
@@ -247,7 +230,6 @@ describe('copyBaseApplicationProject', () => {
});
it('should generate unique UUIDs for each application', async () => {
// Create first app
const firstAppDir = join(testAppDirectory, 'app1');
await fs.ensureDir(firstAppDir);
await copyBaseApplicationProject({
@@ -258,7 +240,6 @@ describe('copyBaseApplicationProject', () => {
exampleOptions: ALL_EXAMPLES,
});
// Create second app
const secondAppDir = join(testAppDirectory, 'app2');
await fs.ensureDir(secondAppDir);
await copyBaseApplicationProject({
@@ -269,7 +250,6 @@ describe('copyBaseApplicationProject', () => {
exampleOptions: ALL_EXAMPLES,
});
// Read both app configs
const firstAppConfig = await fs.readFile(
join(firstAppDir, 'src', APPLICATION_FILE_NAME),
'utf8',
@@ -279,7 +259,6 @@ describe('copyBaseApplicationProject', () => {
'utf8',
);
// Extract UUIDs using regex
const uuidRegex =
/universalIdentifier: '([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})'/;
const firstUuid = firstAppConfig.match(uuidRegex)?.[1];
@@ -291,7 +270,6 @@ describe('copyBaseApplicationProject', () => {
});
it('should generate unique role UUIDs for each application', async () => {
// Create first app
const firstAppDir = join(testAppDirectory, 'app1');
await fs.ensureDir(firstAppDir);
await copyBaseApplicationProject({
@@ -302,7 +280,6 @@ describe('copyBaseApplicationProject', () => {
exampleOptions: ALL_EXAMPLES,
});
// Create second app
const secondAppDir = join(testAppDirectory, 'app2');
await fs.ensureDir(secondAppDir);
await copyBaseApplicationProject({
@@ -323,7 +300,6 @@ describe('copyBaseApplicationProject', () => {
'utf8',
);
// Extract UUIDs using regex
const uuidRegex =
/DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =\s*'([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})'/;
const firstUuid = firstRoleConfig.match(uuidRegex)?.[1];
@@ -402,7 +378,6 @@ describe('copyBaseApplicationProject', () => {
const srcPath = join(testAppDirectory, 'src');
// Core files should exist
expect(await fs.pathExists(join(srcPath, APPLICATION_FILE_NAME))).toBe(
true,
);
@@ -422,7 +397,6 @@ describe('copyBaseApplicationProject', () => {
),
).toBe(true);
// Example files should not exist
expect(
await fs.pathExists(join(srcPath, 'objects', 'example-object.ts')),
).toBe(false);
File diff suppressed because one or more lines are too long
+22 -22
View File
@@ -134,10 +134,10 @@ export type Mutation = {
dismissReconnectAccountBanner: Scalars['Boolean'];
duplicateWorkflow: WorkflowVersionDto;
duplicateWorkflowVersionStep: WorkflowVersionStepChanges;
runWorkflowVersion: RunWorkflowVersionOutput;
runWorkflowVersion: RunWorkflowVersion;
stopWorkflowRun: WorkflowRun;
submitFormStep: Scalars['Boolean'];
testHttpRequest: TestHttpRequestOutput;
testHttpRequest: TestHttpRequest;
updateWorkflowRunStep: WorkflowAction;
updateWorkflowVersionPositions: Scalars['Boolean'];
updateWorkflowVersionStep: WorkflowAction;
@@ -306,6 +306,11 @@ export type QuerySearchArgs = {
searchInput: Scalars['String'];
};
export type RunWorkflowVersion = {
__typename?: 'RunWorkflowVersion';
workflowRunId: Scalars['UUID'];
};
export type RunWorkflowVersionInput = {
/** Execution result in JSON format */
payload?: InputMaybe<Scalars['JSON']>;
@@ -315,11 +320,6 @@ export type RunWorkflowVersionInput = {
workflowVersionId: Scalars['UUID'];
};
export type RunWorkflowVersionOutput = {
__typename?: 'RunWorkflowVersionOutput';
workflowRunId: Scalars['UUID'];
};
export type SearchRecord = {
__typename?: 'SearchRecord';
imageUrl?: Maybe<Scalars['String']>;
@@ -358,19 +358,8 @@ export type SubmitFormStepInput = {
workflowRunId: Scalars['UUID'];
};
export type TestHttpRequestInput = {
/** Request body */
body?: InputMaybe<Scalars['JSON']>;
/** HTTP headers */
headers?: InputMaybe<Scalars['JSON']>;
/** HTTP method */
method: Scalars['String'];
/** URL to make the request to */
url: Scalars['String'];
};
export type TestHttpRequestOutput = {
__typename?: 'TestHttpRequestOutput';
export type TestHttpRequest = {
__typename?: 'TestHttpRequest';
/** Error information */
error?: Maybe<Scalars['JSON']>;
/** Response headers */
@@ -387,6 +376,17 @@ export type TestHttpRequestOutput = {
success: Scalars['Boolean'];
};
export type TestHttpRequestInput = {
/** Request body */
body?: InputMaybe<Scalars['JSON']>;
/** HTTP headers */
headers?: InputMaybe<Scalars['JSON']>;
/** HTTP method */
method: Scalars['String'];
/** URL to make the request to */
url: Scalars['String'];
};
export type TimelineCalendarEvent = {
__typename?: 'TimelineCalendarEvent';
conferenceLink: LinksMetadata;
@@ -722,7 +722,7 @@ export type RunWorkflowVersionMutationVariables = Exact<{
}>;
export type RunWorkflowVersionMutation = { __typename?: 'Mutation', runWorkflowVersion: { __typename?: 'RunWorkflowVersionOutput', workflowRunId: any } };
export type RunWorkflowVersionMutation = { __typename?: 'Mutation', runWorkflowVersion: { __typename?: 'RunWorkflowVersion', workflowRunId: any } };
export type StopWorkflowRunMutationVariables = Exact<{
workflowRunId: Scalars['UUID'];
@@ -757,7 +757,7 @@ export type TestHttpRequestMutationVariables = Exact<{
}>;
export type TestHttpRequestMutation = { __typename?: 'Mutation', testHttpRequest: { __typename?: 'TestHttpRequestOutput', success: boolean, message: string, result?: any | null, error?: any | null, status?: number | null, statusText?: string | null, headers?: any | null } };
export type TestHttpRequestMutation = { __typename?: 'Mutation', testHttpRequest: { __typename?: 'TestHttpRequest', success: boolean, message: string, result?: any | null, error?: any | null, status?: number | null, statusText?: string | null, headers?: any | null } };
export type UpdateWorkflowVersionPositionsMutationVariables = Exact<{
input: UpdateWorkflowVersionPositionsInput;
@@ -170,6 +170,14 @@ const SettingsAvailableApplicationDetails = lazy(() =>
})),
);
const SettingsApplicationRegistrationDetails = lazy(() =>
import(
'~/pages/settings/applications/SettingsApplicationRegistrationDetails'
).then((module) => ({
default: module.SettingsApplicationRegistrationDetails,
})),
);
const SettingsAgentForm = lazy(() =>
import('~/pages/settings/ai/SettingsAgentForm').then((module) => ({
default: module.SettingsAgentForm,
@@ -622,6 +630,10 @@ export const SettingsRoutes = ({ isAdminPageEnabled }: SettingsRoutesProps) => (
path={SettingsPath.AvailableApplicationDetail}
element={<SettingsAvailableApplicationDetails />}
/>
<Route
path={SettingsPath.ApplicationRegistrationDetail}
element={<SettingsApplicationRegistrationDetails />}
/>
<Route
path={SettingsPath.ApplicationLogicFunctionDetail}
element={<SettingsLogicFunctionDetail />}
@@ -3,7 +3,7 @@
import { gql } from '@apollo/client';
export const AVAILABLE_SSO_IDENTITY_PROVIDERS_FRAGMENT = gql`
fragment AvailableSSOIdentityProvidersFragment on FindAvailableSSOIDPOutput {
fragment AvailableSSOIdentityProvidersFragment on FindAvailableSSOIDP {
id
issuer
name
@@ -3,7 +3,7 @@ import { gql } from '@apollo/client';
export const AUTHORIZE_APP = gql`
mutation authorizeApp(
$clientId: String!
$codeChallenge: String!
$codeChallenge: String
$redirectUrl: String!
) {
authorizeApp(
@@ -10,7 +10,7 @@ import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
import { SOURCE_LOCALE } from 'twenty-shared/translations';
import {
type PublicWorkspaceDataOutput,
type PublicWorkspaceData,
useEmailPasswordResetLinkMutation,
} from '~/generated-metadata/graphql';
import { dynamicActivate } from '~/utils/i18n/dynamicActivate';
@@ -24,7 +24,7 @@ dynamicActivate(SOURCE_LOCALE);
const renderHooks = () => {
jotaiStore.set(workspacePublicDataState.atom, {
id: 'workspace-id',
} as PublicWorkspaceDataOutput);
} as PublicWorkspaceData);
const { result } = renderHook(() => useHandleResetPassword(), {
wrapper: ({ children }: { children: ReactNode }) =>
@@ -1,8 +1,8 @@
import { type PublicWorkspaceDataOutput } from '~/generated-metadata/graphql';
import { type PublicWorkspaceData } from '~/generated-metadata/graphql';
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const workspacePublicDataState =
createAtomState<PublicWorkspaceDataOutput | null>({
createAtomState<PublicWorkspaceData | null>({
key: 'workspacePublicDataState',
defaultValue: null,
});
@@ -4,8 +4,9 @@ import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { Card } from 'twenty-ui/layout';
import { isDefined } from 'twenty-shared/utils';
import { type IconComponent } from 'twenty-ui/display';
import { Card } from 'twenty-ui/layout';
const StyledCard = styled(Card)`
background-color: ${({ theme }) => theme.background.secondary};
@@ -33,8 +34,10 @@ const StyledTableCellLabel = styled(TableCell)<{
const StyledTableCellValue = styled(TableCell)<{
align?: 'left' | 'center' | 'right';
clickable?: boolean;
}>`
color: ${({ theme }) => theme.font.color.primary};
cursor: ${({ clickable }) => (clickable ? 'pointer' : 'default')};
height: ${({ theme }) => theme.spacing(6)};
justify-content: ${({ align }) =>
align === 'left'
@@ -48,6 +51,7 @@ type TableItem = {
Icon?: IconComponent;
label: string;
value: string | number | React.ReactNode;
onClick?: () => void;
};
type SettingsAdminTableCardProps = {
@@ -82,7 +86,11 @@ export const SettingsAdminTableCard = ({
{item.Icon && <item.Icon size={theme.icon.size.md} />}
<span>{item.label}</span>
</StyledTableCellLabel>
<StyledTableCellValue align={valueAlign}>
<StyledTableCellValue
align={valueAlign}
onClick={item.onClick}
clickable={isDefined(item.onClick)}
>
{item.value}
</StyledTableCellValue>
</StyledTableRow>
@@ -0,0 +1,19 @@
import { gql } from '@apollo/client';
export const APPLICATION_REGISTRATION_FRAGMENT = gql`
fragment ApplicationRegistrationFragment on ApplicationRegistration {
id
universalIdentifier
name
description
logoUrl
author
oAuthClientId
oAuthRedirectUris
oAuthScopes
websiteUrl
termsUrl
createdAt
updatedAt
}
`;
@@ -0,0 +1,7 @@
import { gql } from '@apollo/client';
export const DELETE_APPLICATION_REGISTRATION = gql`
mutation DeleteApplicationRegistration($id: String!) {
deleteApplicationRegistration(id: $id)
}
`;
@@ -0,0 +1,9 @@
import { gql } from '@apollo/client';
export const ROTATE_APPLICATION_REGISTRATION_CLIENT_SECRET = gql`
mutation RotateApplicationRegistrationClientSecret($id: String!) {
rotateApplicationRegistrationClientSecret(id: $id) {
clientSecret
}
}
`;
@@ -0,0 +1,14 @@
import { gql } from '@apollo/client';
import { APPLICATION_REGISTRATION_FRAGMENT } from '@/settings/application-registrations/graphql/fragments/applicationRegistrationFragment';
export const UPDATE_APPLICATION_REGISTRATION = gql`
mutation UpdateApplicationRegistration(
$input: UpdateApplicationRegistrationInput!
) {
updateApplicationRegistration(input: $input) {
...ApplicationRegistrationFragment
}
}
${APPLICATION_REGISTRATION_FRAGMENT}
`;
@@ -0,0 +1,18 @@
import { gql } from '@apollo/client';
export const UPDATE_APPLICATION_REGISTRATION_VARIABLE = gql`
mutation UpdateApplicationRegistrationVariable(
$input: UpdateApplicationRegistrationVariableInput!
) {
updateApplicationRegistrationVariable(input: $input) {
id
key
description
isSecret
isRequired
isFilled
createdAt
updatedAt
}
}
`;
@@ -0,0 +1,13 @@
import { gql } from '@apollo/client';
export const FIND_APPLICATION_REGISTRATION_BY_CLIENT_ID = gql`
query FindApplicationRegistrationByClientId($clientId: String!) {
findApplicationRegistrationByClientId(clientId: $clientId) {
id
name
oAuthScopes
websiteUrl
logoUrl
}
}
`;
@@ -0,0 +1,14 @@
import { gql } from '@apollo/client';
export const FIND_APPLICATION_REGISTRATION_STATS = gql`
query FindApplicationRegistrationStats($id: String!) {
findApplicationRegistrationStats(id: $id) {
activeInstalls
mostInstalledVersion
versionDistribution {
version
count
}
}
}
`;
@@ -0,0 +1,20 @@
import { gql } from '@apollo/client';
export const FIND_APPLICATION_REGISTRATION_VARIABLES = gql`
query FindApplicationRegistrationVariables(
$applicationRegistrationId: String!
) {
findApplicationRegistrationVariables(
applicationRegistrationId: $applicationRegistrationId
) {
id
key
description
isSecret
isRequired
isFilled
createdAt
updatedAt
}
}
`;
@@ -0,0 +1,12 @@
import { gql } from '@apollo/client';
import { APPLICATION_REGISTRATION_FRAGMENT } from '@/settings/application-registrations/graphql/fragments/applicationRegistrationFragment';
export const FIND_MANY_APPLICATION_REGISTRATIONS = gql`
query FindManyApplicationRegistrations {
findManyApplicationRegistrations {
...ApplicationRegistrationFragment
}
}
${APPLICATION_REGISTRATION_FRAGMENT}
`;
@@ -0,0 +1,12 @@
import { gql } from '@apollo/client';
import { APPLICATION_REGISTRATION_FRAGMENT } from '@/settings/application-registrations/graphql/fragments/applicationRegistrationFragment';
export const FIND_ONE_APPLICATION_REGISTRATION = gql`
query FindOneApplicationRegistration($id: String!) {
findOneApplicationRegistration(id: $id) {
...ApplicationRegistrationFragment
}
}
${APPLICATION_REGISTRATION_FRAGMENT}
`;
@@ -8,10 +8,10 @@ import { supportChatState } from '@/client-config/states/supportChatState';
import { usePermissionFlagMap } from '@/settings/roles/hooks/usePermissionFlagMap';
import { getDocumentationUrl } from '@/support/utils/getDocumentationUrl';
import { type NavigationDrawerItemIndentationLevel } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { t } from '@lingui/core/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import {
IconApi,
// IconApps, // TODO: Re-enable when integrations page is ready
@@ -173,7 +173,7 @@ const useSettingsNavigationItems = (): SettingsNavigationSection[] => {
// isHidden: !permissionMap[PermissionFlagType.API_KEYS_AND_WEBHOOKS],
// },
{
label: t`Applications`,
label: t`Apps`,
path: SettingsPath.Applications,
Icon: IconPlug,
isHidden:
@@ -1,7 +1,7 @@
import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState';
import { useMemo } from 'react';
import {
type TwoFactorAuthenticationMethodDto,
type TwoFactorAuthenticationMethodSummary,
useInitiateOtpProvisioningMutation,
} from '~/generated-metadata/graphql';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
@@ -12,7 +12,7 @@ export const useCurrentUserWorkspaceTwoFactorAuthentication = () => {
useInitiateOtpProvisioningMutation();
const currentUserWorkspaceTwoFactorAuthenticationMethods = useMemo(() => {
const methods: Record<string, TwoFactorAuthenticationMethodDto> = {};
const methods: Record<string, TwoFactorAuthenticationMethodSummary> = {};
(currentUserWorkspace?.twoFactorAuthenticationMethodSummary ?? []).forEach(
(method) => (methods[method.strategy] = method),
@@ -1,3 +1,4 @@
import { FIND_APPLICATION_REGISTRATION_BY_CLIENT_ID } from '@/settings/application-registrations/graphql/queries/findApplicationRegistrationByClientId';
import styled from '@emotion/styled';
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
@@ -5,14 +6,15 @@ import { AppPath } from 'twenty-shared/types';
import { useRedirect } from '@/domain-manager/hooks/useRedirect';
import { Trans, useLingui } from '@lingui/react/macro';
import { useQuery } from '@apollo/client';
import { isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
import { Avatar } from 'twenty-ui/display';
import { MainButton } from 'twenty-ui/input';
import { UndecoratedLink } from 'twenty-ui/navigation';
import { useAuthorizeAppMutation } from '~/generated-metadata/graphql';
import { useNavigateApp } from '~/hooks/useNavigateApp';
type App = { id: string; name: string; logo: string };
const StyledContainer = styled.div`
display: flex;
align-items: center;
@@ -56,52 +58,85 @@ const StyledButtonContainer = styled.div`
gap: 10px;
width: 100%;
`;
const StyledScopeList = styled.ul`
list-style: none;
padding: 0;
margin: 0 0 ${({ theme }) => theme.spacing(4)} 0;
width: 100%;
`;
const StyledScopeItem = styled.li`
color: ${({ theme }) => theme.font.color.secondary};
font-size: ${({ theme }) => theme.font.size.md};
padding: ${({ theme }) => theme.spacing(1)} 0;
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
&:last-child {
border-bottom: none;
}
`;
export const Authorize = () => {
const { t } = useLingui();
const navigate = useNavigateApp();
const [searchParam] = useSearchParams();
const { redirect } = useRedirect();
//TODO: Replace with db call for registered third party apps
const [apps] = useState<App[]>([
{
id: 'chrome',
name: 'Chrome Extension',
logo: 'images/integrations/chrome-icon.svg',
},
]);
const [app, setApp] = useState<App>();
const oauthScopeLabels: { [scope: string]: string | undefined } = {
api: t`Access workspace data`,
profile: t`Read your profile`,
};
const clientId = searchParam.get('clientId');
const codeChallenge = searchParam.get('codeChallenge');
const redirectUrl = searchParam.get('redirectUrl');
useEffect(() => {
const app = apps.find((app) => app.id === clientId);
if (!isDefined(app)) navigate(AppPath.NotFound);
else setApp(app);
//eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const { data, loading } = useQuery(
FIND_APPLICATION_REGISTRATION_BY_CLIENT_ID,
{
variables: { clientId: clientId ?? '' },
skip: !isDefined(clientId),
},
);
const applicationRegistration = data?.findApplicationRegistrationByClientId;
const [authorizeApp] = useAuthorizeAppMutation();
const [hasLogoError, setHasLogoError] = useState(false);
const shouldRedirectToNotFound =
!isDefined(clientId) || (!loading && !isDefined(applicationRegistration));
useEffect(() => {
if (shouldRedirectToNotFound) {
navigate(AppPath.NotFound);
}
}, [shouldRedirectToNotFound, navigate]);
const handleAuthorize = async () => {
if (
isDefined(clientId) &&
isDefined(codeChallenge) &&
isDefined(redirectUrl)
) {
if (isDefined(clientId) && isDefined(redirectUrl)) {
await authorizeApp({
variables: {
clientId,
codeChallenge,
codeChallenge: codeChallenge ?? undefined,
redirectUrl,
},
onCompleted: (data) => {
redirect(data.authorizeApp.redirectUrl);
onCompleted: (responseData) => {
redirect(responseData.authorizeApp.redirectUrl);
},
});
}
};
const appName = app?.name;
if (loading || !applicationRegistration) {
return null;
}
const appName = applicationRegistration.name;
const appLogoUrl = applicationRegistration.logoUrl;
const requestedScopes: string[] = applicationRegistration.oAuthScopes ?? [];
const showLogoImage = isNonEmptyString(appLogoUrl) && !hasLogoError;
return (
<StyledContainer>
@@ -119,11 +154,36 @@ export const Authorize = () => {
height={60}
width={60}
/>
<img src={app?.logo} alt="app-icon" height={40} width={40} />
{showLogoImage ? (
<img
src={appLogoUrl}
alt={appName}
height={40}
width={40}
style={{ borderRadius: '2px' }}
onError={() => setHasLogoError(true)}
/>
) : (
<Avatar
size="xl"
placeholder={appName}
placeholderColorSeed={appName}
type="squared"
/>
)}
</StyledAppsContainer>
<StyledText>
<Trans>{appName} wants to access your account</Trans>
</StyledText>
{requestedScopes.length > 0 && (
<StyledScopeList>
{requestedScopes.map((scope) => (
<StyledScopeItem key={scope}>
{oauthScopeLabels[scope] ?? scope}
</StyledScopeItem>
))}
</StyledScopeList>
)}
<StyledButtonContainer>
<UndecoratedLink to={AppPath.Index}>
<MainButton title={t`Cancel`} variant="secondary" fullWidth />
@@ -35,7 +35,7 @@ import { useSearchParams } from 'react-router-dom';
import { isDefined } from 'twenty-shared/utils';
import { Loader } from 'twenty-ui/feedback';
import { AnimatedEaseIn } from 'twenty-ui/utilities';
import { type PublicWorkspaceDataOutput } from '~/generated-metadata/graphql';
import { type PublicWorkspaceData } from '~/generated-metadata/graphql';
const StyledLoaderContainer = styled.div`
align-items: center;
@@ -53,7 +53,7 @@ const StandardContent = ({
title,
onClickOnLogo,
}: {
workspacePublicData: PublicWorkspaceDataOutput | null;
workspacePublicData: PublicWorkspaceData | null;
signInUpForm: JSX.Element | null;
signInUpStep: SignInUpStep;
title: string;
@@ -27,7 +27,7 @@ const buildHandlers = (hasPassword: boolean) => [
HttpResponse.json({
data: {
validatePasswordResetToken: {
__typename: 'ValidatePasswordResetTokenOutput',
__typename: 'ValidatePasswordResetToken',
id: mockedOnboardingUsersData.id,
email: mockedOnboardingUsersData.email,
hasPassword,
@@ -0,0 +1,626 @@
import { SettingsAdminTableCard } from '@/settings/admin-panel/components/SettingsAdminTableCard';
import { DELETE_APPLICATION_REGISTRATION } from '@/settings/application-registrations/graphql/mutations/deleteApplicationRegistration';
import { ROTATE_APPLICATION_REGISTRATION_CLIENT_SECRET } from '@/settings/application-registrations/graphql/mutations/rotateApplicationRegistrationClientSecret';
import { UPDATE_APPLICATION_REGISTRATION } from '@/settings/application-registrations/graphql/mutations/updateApplicationRegistration';
import { UPDATE_APPLICATION_REGISTRATION_VARIABLE } from '@/settings/application-registrations/graphql/mutations/updateApplicationRegistrationVariable';
import { FIND_APPLICATION_REGISTRATION_STATS } from '@/settings/application-registrations/graphql/queries/findApplicationRegistrationStats';
import { FIND_APPLICATION_REGISTRATION_VARIABLES } from '@/settings/application-registrations/graphql/queries/findApplicationRegistrationVariables';
import { FIND_MANY_APPLICATION_REGISTRATIONS } from '@/settings/application-registrations/graphql/queries/findManyApplicationRegistrations';
import { FIND_ONE_APPLICATION_REGISTRATION } from '@/settings/application-registrations/graphql/queries/findOneApplicationRegistration';
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { ApiKeyInput } from '@/settings/developers/components/ApiKeyInput';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
import { useMutation, useQuery } from '@apollo/client';
import styled from '@emotion/styled';
import { Trans, useLingui } from '@lingui/react/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { useState } from 'react';
import { useParams } from 'react-router-dom';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined, isValidUrl } from 'twenty-shared/utils';
import {
H2Title,
IconChartBar,
IconCheck,
IconDownload,
IconKey,
IconRefresh,
IconShield,
IconTag,
IconTextCaption,
IconTrash,
IconWorld,
Status,
} from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { applicationRegistrationClientSecretFamilyState } from '~/pages/settings/applications/states/applicationRegistrationClientSecretFamilyState';
const DELETE_REGISTRATION_MODAL_ID = 'delete-application-registration-modal';
const ROTATE_SECRET_MODAL_ID = 'rotate-application-registration-secret-modal';
const StyledInputContainer = styled.div`
align-items: center;
display: flex;
flex-direction: row;
gap: ${({ theme }) => theme.spacing(2)};
width: 100%;
`;
const StyledRedirectUriRow = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
padding: ${({ theme }) => theme.spacing(1)} 0;
`;
const StyledRedirectUriValue = styled.span`
color: ${({ theme }) => theme.font.color.primary};
font-family: monospace;
word-break: break-all;
`;
const StyledVariableRow = styled.div`
align-items: center;
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
padding: ${({ theme }) => theme.spacing(2)} 0;
`;
const StyledVariableInfo = styled.div`
flex: 1;
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(0.5)};
`;
const StyledVariableKey = styled.span`
color: ${({ theme }) => theme.font.color.primary};
font-family: monospace;
font-weight: ${({ theme }) => theme.font.weight.medium};
`;
const StyledVariableDescription = styled.span`
color: ${({ theme }) => theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.sm};
`;
const StyledRotateContainer = styled.div`
padding-top: ${({ theme }) => theme.spacing(2)};
`;
type ServerVariable = {
id: string;
key: string;
description: string;
isSecret: boolean;
isRequired: boolean;
isFilled: boolean;
};
export const SettingsApplicationRegistrationDetails = () => {
const { t } = useLingui();
const navigate = useNavigateSettings();
const { copyToClipboard } = useCopyToClipboard();
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const { openModal } = useModal();
const { applicationRegistrationId = '' } = useParams<{
applicationRegistrationId: string;
}>();
const applicationRegistrationClientSecret = useAtomFamilyStateValue(
applicationRegistrationClientSecretFamilyState,
applicationRegistrationId,
);
const [isLoading, setIsLoading] = useState(false);
const [formRedirectUris, setFormRedirectUris] = useState<string[]>([]);
const [newRedirectUri, setNewRedirectUri] = useState('');
const [hasChanges, setHasChanges] = useState(false);
const [rotatedSecret, setRotatedSecret] = useState<string | null>(null);
const [variableValues, setVariableValues] = useState<Record<string, string>>(
{},
);
const { data, loading } = useQuery(FIND_ONE_APPLICATION_REGISTRATION, {
variables: { id: applicationRegistrationId },
skip: !applicationRegistrationId,
onCompleted: (result) => {
const foundRegistration = result?.findOneApplicationRegistration;
if (isDefined(foundRegistration)) {
setFormRedirectUris(foundRegistration.oAuthRedirectUris ?? []);
}
},
});
const { data: variablesData } = useQuery(
FIND_APPLICATION_REGISTRATION_VARIABLES,
{
variables: { applicationRegistrationId },
skip: !applicationRegistrationId,
},
);
const { data: statsData } = useQuery(FIND_APPLICATION_REGISTRATION_STATS, {
variables: { id: applicationRegistrationId },
skip: !applicationRegistrationId,
});
const [updateRegistration] = useMutation(UPDATE_APPLICATION_REGISTRATION, {
refetchQueries: [
FIND_ONE_APPLICATION_REGISTRATION,
FIND_MANY_APPLICATION_REGISTRATIONS,
],
});
const [deleteRegistration] = useMutation(DELETE_APPLICATION_REGISTRATION, {
refetchQueries: [FIND_MANY_APPLICATION_REGISTRATIONS],
});
const [rotateSecret] = useMutation(
ROTATE_APPLICATION_REGISTRATION_CLIENT_SECRET,
);
const [updateVariable] = useMutation(
UPDATE_APPLICATION_REGISTRATION_VARIABLE,
{
refetchQueries: [FIND_APPLICATION_REGISTRATION_VARIABLES],
},
);
const registration = data?.findOneApplicationRegistration;
const variables: ServerVariable[] =
variablesData?.findApplicationRegistrationVariables ?? [];
if (loading || !registration) {
return null;
}
const markDirty = () => setHasChanges(true);
const handleSave = async () => {
setIsLoading(true);
try {
await updateRegistration({
variables: {
input: {
id: applicationRegistrationId,
update: {
oAuthRedirectUris: formRedirectUris,
},
},
},
});
setHasChanges(false);
enqueueSuccessSnackBar({ message: t`App updated` });
} catch {
enqueueErrorSnackBar({ message: t`Error updating app` });
} finally {
setIsLoading(false);
}
};
const handleCancel = () => {
setFormRedirectUris(registration.oAuthRedirectUris ?? []);
setHasChanges(false);
};
const handleDelete = async () => {
setIsLoading(true);
try {
await deleteRegistration({
variables: { id: applicationRegistrationId },
});
navigate(SettingsPath.Applications);
} catch {
enqueueErrorSnackBar({
message: t`Error deleting app`,
});
} finally {
setIsLoading(false);
}
};
const handleRotateSecret = async () => {
setIsLoading(true);
try {
const result = await rotateSecret({
variables: { id: applicationRegistrationId },
});
const secret =
result.data?.rotateApplicationRegistrationClientSecret?.clientSecret;
if (isNonEmptyString(secret)) {
setRotatedSecret(secret);
enqueueSuccessSnackBar({
message: t`Client secret rotated. Copy it now — it won't be shown again.`,
});
}
} catch {
enqueueErrorSnackBar({
message: t`Error rotating client secret`,
});
} finally {
setIsLoading(false);
}
};
const handleAddRedirectUri = () => {
const trimmed = newRedirectUri.trim();
if (!trimmed) {
return;
}
if (!isValidUrl(trimmed)) {
enqueueErrorSnackBar({ message: t`Please enter a valid URL` });
return;
}
if (formRedirectUris.includes(trimmed)) {
enqueueErrorSnackBar({ message: t`This redirect URI is already added` });
return;
}
setFormRedirectUris([...formRedirectUris, trimmed]);
setNewRedirectUri('');
markDirty();
};
const handleRemoveRedirectUri = (index: number) => {
setFormRedirectUris(
formRedirectUris.filter((_, uriIndex) => uriIndex !== index),
);
markDirty();
};
const handleSaveVariableValue = async (variable: ServerVariable) => {
const value = variableValues[variable.id];
const variableKey = variable.key;
if (!isNonEmptyString(value)) {
return;
}
try {
await updateVariable({
variables: {
input: {
id: variable.id,
update: {
value,
},
},
},
});
setVariableValues((previous) => {
const next = { ...previous };
delete next[variable.id];
return next;
});
enqueueSuccessSnackBar({
message: t`Variable ${variableKey} updated`,
});
} catch {
enqueueErrorSnackBar({
message: t`Error updating variable`,
});
}
};
const displayedSecret = applicationRegistrationClientSecret ?? rotatedSecret;
const confirmationValue = t`yes`;
const credentialItems = [
{
Icon: IconKey,
label: t`Client ID`,
value: registration.oAuthClientId,
onClick: () =>
copyToClipboard(registration.oAuthClientId, t`Client ID copied`),
},
{
Icon: IconShield,
label: t`Scopes`,
value: (registration.oAuthScopes ?? []).join(', ') || '—',
},
];
const generalItems = [
{
Icon: IconTag,
label: t`Name`,
value: registration.name,
},
...(isNonEmptyString(registration.description)
? [
{
Icon: IconTextCaption,
label: t`Description`,
value: registration.description,
},
]
: []),
{
Icon: IconWorld,
label: t`Universal ID`,
value: registration.universalIdentifier,
onClick: () =>
copyToClipboard(
registration.universalIdentifier,
t`Universal identifier copied`,
),
},
];
const stats = statsData?.findApplicationRegistrationStats;
const hasActiveInstalls = (stats?.activeInstalls ?? 0) > 0;
const versionDistributionLabel =
stats?.versionDistribution
?.map(
(entry: { version: string; count: number }) =>
`${entry.version} (${entry.count})`,
)
.join(', ') || '—';
const statsItems = [
{
Icon: IconDownload,
label: t`Active installs`,
value: stats?.activeInstalls ?? '—',
},
{
Icon: IconTag,
label: t`Most installed version`,
value: stats?.mostInstalledVersion ?? '—',
},
{
Icon: IconChartBar,
label: t`Distribution`,
value: versionDistributionLabel,
},
];
return (
<>
<SubMenuTopBarContainer
title={registration.name}
links={[
{
children: t`Workspace`,
href: getSettingsPath(SettingsPath.Workspace),
},
{
children: t`Applications`,
href: getSettingsPath(SettingsPath.Applications),
},
{ children: registration.name },
]}
actionButton={
hasChanges ? (
<SaveAndCancelButtons
isSaveDisabled={isLoading}
onCancel={handleCancel}
onSave={handleSave}
/>
) : undefined
}
>
<SettingsPageContainer>
{stats && stats.activeInstalls > 0 && (
<Section>
<H2Title
title={t`Install Stats`}
description={t`Usage across all workspaces on this server`}
/>
<SettingsAdminTableCard
rounded
items={statsItems}
gridAutoColumns="3fr 8fr"
/>
</Section>
)}
<Section>
<H2Title
title={t`General`}
description={t`Name and description are managed via your app manifest (CLI)`}
/>
<SettingsAdminTableCard
rounded
items={generalItems}
gridAutoColumns="3fr 8fr"
/>
</Section>
<Section>
<H2Title
title={t`OAuth Credentials`}
description={t`Credentials and scopes for OAuth authorization flows`}
/>
<SettingsAdminTableCard
rounded
items={credentialItems}
gridAutoColumns="3fr 8fr"
/>
<StyledRotateContainer>
<Button
Icon={IconRefresh}
title={t`Rotate client secret`}
variant="secondary"
onClick={() => openModal(ROTATE_SECRET_MODAL_ID)}
/>
</StyledRotateContainer>
</Section>
{displayedSecret && (
<Section>
<H2Title
title={t`Client Secret`}
description={t`Copy this secret as it will not be visible again`}
/>
<ApiKeyInput apiKey={displayedSecret} />
</Section>
)}
<Section>
<H2Title
title={t`Redirect URIs`}
description={t`Allowed redirect URIs for OAuth flows`}
/>
{formRedirectUris.map((uri, index) => (
<StyledRedirectUriRow key={`${uri}-${index}`}>
<StyledRedirectUriValue>{uri}</StyledRedirectUriValue>
<Button
Icon={IconTrash}
variant="tertiary"
size="medium"
onClick={() => handleRemoveRedirectUri(index)}
/>
</StyledRedirectUriRow>
))}
<StyledInputContainer>
<SettingsTextInput
instanceId="application-registration-new-redirect-uri"
value={newRedirectUri}
onChange={setNewRedirectUri}
placeholder={t`https://example.com/callback`}
fullWidth
/>
<Button
title={t`Add`}
variant="secondary"
size="medium"
onClick={handleAddRedirectUri}
/>
</StyledInputContainer>
</Section>
{variables.length > 0 && (
<Section>
<H2Title
title={t`Server Variables`}
description={t`Variables declared by the app manifest. Fill in values here — they apply to all workspace installations.`}
/>
{variables.map((variable) => (
<StyledVariableRow key={variable.id}>
<StyledVariableInfo>
<StyledVariableKey>
{variable.key}
{variable.isRequired && (
<span style={{ color: 'red' }}> *</span>
)}
</StyledVariableKey>
{isNonEmptyString(variable.description) && (
<StyledVariableDescription>
{variable.description}
</StyledVariableDescription>
)}
</StyledVariableInfo>
{variable.isFilled &&
!isNonEmptyString(variableValues[variable.id]) && (
<Status color="green" text={t`Configured`} />
)}
{!variable.isFilled &&
!isNonEmptyString(variableValues[variable.id]) && (
<Status
color={variable.isRequired ? 'red' : 'gray'}
text={variable.isRequired ? t`Required` : t`Not set`}
/>
)}
<SettingsTextInput
instanceId={`var-${variable.id}`}
value={variableValues[variable.id] ?? ''}
onChange={(value) =>
setVariableValues((previous) => ({
...previous,
[variable.id]: value,
}))
}
placeholder={
variable.isSecret ? t`Enter secret value` : t`Enter value`
}
fullWidth
/>
<Button
Icon={IconCheck}
variant="secondary"
size="small"
disabled={!isNonEmptyString(variableValues[variable.id])}
onClick={() => handleSaveVariableValue(variable)}
/>
</StyledVariableRow>
))}
</Section>
)}
<Section>
<H2Title
title={t`Danger zone`}
description={
hasActiveInstalls
? t`Uninstall this app from all workspaces before deleting it`
: t`Delete this app`
}
/>
<Button
accent="danger"
variant="secondary"
title={t`Delete`}
Icon={IconTrash}
disabled={hasActiveInstalls}
onClick={() => openModal(DELETE_REGISTRATION_MODAL_ID)}
/>
</Section>
</SettingsPageContainer>
</SubMenuTopBarContainer>
<ConfirmationModal
confirmationPlaceholder={confirmationValue}
confirmationValue={confirmationValue}
modalId={ROTATE_SECRET_MODAL_ID}
title={t`Rotate client secret`}
subtitle={
<Trans>
If you rotate this secret, any integration using the current secret
will stop working. Please type {`"${confirmationValue}"`} to
confirm.
</Trans>
}
onConfirmClick={handleRotateSecret}
confirmButtonText={t`Rotate secret`}
loading={isLoading}
/>
<ConfirmationModal
confirmationPlaceholder={confirmationValue}
confirmationValue={confirmationValue}
modalId={DELETE_REGISTRATION_MODAL_ID}
title={t`Delete app`}
subtitle={
<Trans>
Please type {`"${confirmationValue}"`} to confirm you want to delete
this app. All workspace installations linked to it will lose their
OAuth credentials.
</Trans>
}
onConfirmClick={handleDelete}
confirmButtonText={t`Delete`}
loading={isLoading}
/>
</>
);
};
@@ -1,3 +1,4 @@
import { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
@@ -10,11 +11,12 @@ import { getSettingsPath } from 'twenty-shared/utils';
import { IconApps, IconCode, IconDownload } from 'twenty-ui/display';
import {
type FeatureFlagKey,
PermissionFlagType,
useFindManyApplicationsQuery,
} from '~/generated-metadata/graphql';
import { SettingsApplicationsTable } from '~/pages/settings/applications/components/SettingsApplicationsTable';
import { SettingsApplicationsAvailableTab } from '~/pages/settings/applications/tabs/SettingsApplicationsAvailableTab';
import { SettingsApplicationsCreateTab } from '~/pages/settings/applications/tabs/SettingsApplicationsCreateTab';
import { SettingsApplicationsDeveloperTab } from '~/pages/settings/applications/tabs/SettingsApplicationsDeveloperTab';
import { SettingsApplicationsInstalledTab } from '~/pages/settings/applications/tabs/SettingsApplicationsInstalledTab';
const APPLICATIONS_TAB_LIST_ID = 'applications-tab-list';
@@ -22,6 +24,10 @@ const APPLICATIONS_TAB_LIST_ID = 'applications-tab-list';
export const SettingsApplications = () => {
const { t } = useLingui();
const hasDeveloperAccess = useHasPermissionFlag(
PermissionFlagType.API_KEYS_AND_WEBHOOKS,
);
const isMarketplaceEnabled = useIsFeatureEnabled(
'IS_MARKETPLACE_ENABLED' as FeatureFlagKey,
);
@@ -51,26 +57,28 @@ export const SettingsApplications = () => {
{applications.length > 0 && (
<SettingsApplicationsTable applications={applications} />
)}
<SettingsApplicationsCreateTab />
{hasDeveloperAccess && <SettingsApplicationsDeveloperTab />}
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
}
const tabs = [
{ id: 'available', title: t`Available`, Icon: IconDownload },
{ id: 'marketplace', title: t`Marketplace`, Icon: IconDownload },
{ id: 'installed', title: t`Installed`, Icon: IconApps },
{ id: 'create', title: t`Create an app`, Icon: IconCode },
...(hasDeveloperAccess
? [{ id: 'developer', title: t`Developer`, Icon: IconCode }]
: []),
];
const renderActiveTabContent = () => {
switch (activeTabId) {
case 'available':
case 'marketplace':
return <SettingsApplicationsAvailableTab />;
case 'installed':
return <SettingsApplicationsInstalledTab />;
case 'create':
return <SettingsApplicationsCreateTab />;
case 'developer':
return <SettingsApplicationsDeveloperTab />;
default:
return <SettingsApplicationsAvailableTab />;
}
@@ -0,0 +1,7 @@
import { createAtomFamilyState } from '@/ui/utilities/state/jotai/utils/createAtomFamilyState';
export const applicationRegistrationClientSecretFamilyState =
createAtomFamilyState<string | null, string>({
key: 'applicationRegistrationClientSecretState',
defaultValue: null,
});
@@ -1,68 +0,0 @@
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { getDocumentationUrl } from '@/support/utils/getDocumentationUrl';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import {
CommandBlock,
H2Title,
IconCopy,
IconFileInfo,
} from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
const StyledButtonContainer = styled.div`
margin: ${({ theme }) => theme.spacing(2)} 0;
`;
export const SettingsApplicationsCreateTab = () => {
const { t } = useLingui();
const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState);
const { copyToClipboard } = useCopyToClipboard();
const commands = [
// eslint-disable-next-line lingui/no-unlocalized-strings
'npx create-twenty-app@latest my-twenty-app',
// eslint-disable-next-line lingui/no-unlocalized-strings
'cd my-twenty-app',
];
const button = (
<Button
onClick={() => {
copyToClipboard(commands.join('\n'), t`Commands copied to clipboard`);
}}
ariaLabel={t`Copy commands`}
Icon={IconCopy}
/>
);
return (
<Section>
<H2Title
title={t`Create an application`}
description={t`You can either create a private app or share it to others`}
/>
<CommandBlock commands={commands} button={button} />
<StyledButtonContainer>
<Button
Icon={IconFileInfo}
title={t`Read documentation`}
onClick={() =>
window.open(
getDocumentationUrl({
locale: currentWorkspaceMember?.locale,
path: '/developers/extend/capabilities/apps',
}),
'_blank',
)
}
/>
</StyledButtonContainer>
</Section>
);
};
@@ -0,0 +1,118 @@
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { FIND_MANY_APPLICATION_REGISTRATIONS } from '@/settings/application-registrations/graphql/queries/findManyApplicationRegistrations';
import { SettingsListCard } from '@/settings/components/SettingsListCard';
import { getDocumentationUrl } from '@/support/utils/getDocumentationUrl';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import styled from '@emotion/styled';
import { useTheme } from '@emotion/react';
import { useLingui } from '@lingui/react/macro';
import { useQuery } from '@apollo/client';
import { useNavigate } from 'react-router-dom';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import {
CommandBlock,
H2Title,
IconApps,
IconChevronRight,
IconCopy,
IconFileInfo,
} from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
const StyledButtonContainer = styled.div`
margin: ${({ theme }) => theme.spacing(2)} 0;
`;
type ApplicationRegistration = {
id: string;
name: string;
description: string | null;
};
export const SettingsApplicationsDeveloperTab = () => {
const { t } = useLingui();
const navigate = useNavigate();
const theme = useTheme();
const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState);
const { copyToClipboard } = useCopyToClipboard();
const { data, loading } = useQuery(FIND_MANY_APPLICATION_REGISTRATIONS);
const registrations: ApplicationRegistration[] =
data?.findManyApplicationRegistrations ?? [];
const commands = [
// eslint-disable-next-line lingui/no-unlocalized-strings
'npx create-twenty-app@latest my-twenty-app',
// eslint-disable-next-line lingui/no-unlocalized-strings
'cd my-twenty-app',
];
const copyButton = (
<Button
onClick={() => {
copyToClipboard(commands.join('\n'), t`Commands copied to clipboard`);
}}
ariaLabel={t`Copy commands`}
Icon={IconCopy}
/>
);
return (
<>
<Section>
<H2Title
title={t`Create an application`}
description={t`You can either create a private app or share it to others`}
/>
<CommandBlock commands={commands} button={copyButton} />
<StyledButtonContainer>
<Button
Icon={IconFileInfo}
title={t`Read documentation`}
onClick={() =>
window.open(
getDocumentationUrl({
locale: currentWorkspaceMember?.locale,
path: '/developers/extend/capabilities/apps',
}),
'_blank',
)
}
/>
</StyledButtonContainer>
</Section>
{registrations.length > 0 && (
<Section>
<H2Title
title={t`My Apps`}
description={t`Apps you've created and published`}
/>
<SettingsListCard
items={registrations}
getItemLabel={(registration) => registration.name}
isLoading={loading}
RowIcon={IconApps}
onRowClick={(registration) => {
navigate(
getSettingsPath(SettingsPath.ApplicationRegistrationDetail, {
applicationRegistrationId: registration.id,
}),
);
}}
RowRightComponent={() => (
<IconChevronRight
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
)}
/>
</Section>
)}
</>
);
};
@@ -3,7 +3,7 @@ import type { ListPlansQueryResult } from '~/generated-metadata/graphql';
export const mockBillingPlans = {
listPlans: [
{
__typename: 'BillingPlanOutput',
__typename: 'BillingPlan',
planKey: 'PRO',
licensedProducts: [
{
@@ -293,7 +293,7 @@ export const mockBillingPlans = {
],
},
{
__typename: 'BillingPlanOutput',
__typename: 'BillingPlan',
planKey: 'ENTERPRISE',
licensedProducts: [
{
@@ -2,7 +2,7 @@ import { type GetPublicWorkspaceDataByDomainQuery } from '~/generated-metadata/g
export const mockedPublicWorkspaceDataBySubdomain: GetPublicWorkspaceDataByDomainQuery['getPublicWorkspaceDataByDomain'] =
{
__typename: 'PublicWorkspaceDataOutput',
__typename: 'PublicWorkspaceData',
id: '9870323e-22c3-4d14-9b7f-5bdc84f7d6ee',
logo: 'workspace-logo/original/c88deb49-7636-4560-918d-08c3265ffb20.49?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ3b3Jrc3BhY2VJZCI6Ijk4NzAzMjNlLTIyYzMtNGQxNC05YjdmLTViZGM4NGY3ZDZlZSIsImlhdCI6MTczNjU0MDU0MywiZXhwIjoxNzM2NjI2OTQzfQ.C8cnHu09VGseRbQAMM4nhiO6z4TLG03ntFTvxm53-xg',
displayName: 'Twenty Eng',
@@ -103,7 +103,7 @@ export const mockCurrentWorkspace = {
phases: [],
billingSubscriptionItems: [
{
__typename: 'BillingSubscriptionItemDTO',
__typename: 'BillingSubscriptionItem',
id: '11111111-1111-4111-8111-111111111111',
hasReachedCurrentPeriodCap: false,
quantity: 1,
@@ -116,7 +116,7 @@ export const mockCurrentWorkspace = {
},
},
{
__typename: 'BillingSubscriptionItemDTO',
__typename: 'BillingSubscriptionItem',
id: '11111111-1111-4111-8111-111111111112',
hasReachedCurrentPeriodCap: false,
quantity: null,
@@ -140,7 +140,7 @@ export const mockCurrentWorkspace = {
phases: [],
billingSubscriptionItems: [
{
__typename: 'BillingSubscriptionItemDTO',
__typename: 'BillingSubscriptionItem',
id: '22222222-2222-4222-8222-222222222222',
hasReachedCurrentPeriodCap: false,
quantity: 1,
@@ -10,7 +10,17 @@ describe('rich-app app:dev', () => {
beforeAll(async () => {
const result = await runAppDevInProcess({ appPath: APP_PATH });
expect(result.success).toBe(true);
if (!result.success) {
const diagnostics = JSON.stringify(
{ events: result.events, stepStatuses: result.stepStatuses },
null,
2,
);
throw new Error(
`app:dev did not produce manifest.json within timeout.\n${diagnostics}`,
);
}
}, 60000);
defineManifestTests(APP_PATH);
@@ -28,6 +28,18 @@ export const EXPECTED_MANIFEST: Manifest = {
value: 'Alex Karp',
},
},
serverVariables: {
POSTCARD_API_KEY: {
description: 'API key for the postcard printing service',
isSecret: true,
isRequired: true,
},
POSTCARD_SENDER_NAME: {
description: 'Default sender name on postcards',
isSecret: false,
isRequired: false,
},
},
description: 'A simple rich app',
displayName: 'Rich App',
defaultRoleUniversalIdentifier: 'b648f87b-1d26-4961-b974-0908fd991061',
@@ -14,5 +14,17 @@ export default defineApplication({
isSecret: false,
},
},
serverVariables: {
POSTCARD_API_KEY: {
description: 'API key for the postcard printing service',
isSecret: true,
isRequired: true,
},
POSTCARD_SENDER_NAME: {
description: 'Default sender name on postcards',
isSecret: false,
isRequired: false,
},
},
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
});
@@ -11,7 +11,17 @@ describe('root-app app:dev', () => {
beforeAll(async () => {
const result = await runAppDevInProcess({ appPath: APP_PATH });
expect(result.success).toBe(true);
if (!result.success) {
const diagnostics = JSON.stringify(
{ events: result.events, stepStatuses: result.stepStatuses },
null,
2,
);
throw new Error(
`app:dev did not produce manifest.json within timeout.\n${diagnostics}`,
);
}
}, 60000);
defineManifestTests(APP_PATH);
@@ -5,6 +5,8 @@ import { OUTPUT_DIR } from 'twenty-shared/application';
export type RunAppDevResult = {
success: boolean;
events?: { message: string; status: string }[];
stepStatuses?: Record<string, string>;
};
export const runAppDevInProcess = async (options: {
@@ -22,7 +24,6 @@ export const runAppDevInProcess = async (options: {
while (Date.now() - startTime < timeout) {
if (await fs.pathExists(manifestPath)) {
// Small delay to let any pending writes finish
await new Promise((resolve) => setTimeout(resolve, 500));
await command.close();
@@ -31,7 +32,20 @@ export const runAppDevInProcess = async (options: {
await new Promise((resolve) => setTimeout(resolve, 100));
}
const state = command.getOrchestrator()?.getState();
const events = state?.events.map((event) => ({
message: event.message,
status: event.status,
}));
const stepStatuses = state
? Object.fromEntries(
Object.entries(state.steps).map(([key, step]) => [key, step.status]),
)
: undefined;
await command.close();
return { success: false };
return { success: false, events, stepStatuses };
};
@@ -26,6 +26,19 @@ const mockApiService = {
},
},
}),
findApplicationRegistrationByUniversalIdentifier: vi
.fn()
.mockResolvedValue({ success: true, data: null }),
createApplicationRegistration: vi.fn().mockResolvedValue({
success: true,
data: {
applicationRegistration: {
id: 'mock-registration-id',
oAuthClientId: 'mock-client-id',
},
clientSecret: 'mock-client-secret',
},
}),
syncApplication: vi.fn().mockResolvedValue({ success: true, data: true }),
uploadFile: vi.fn().mockResolvedValue({ success: true, data: true }),
};
@@ -37,6 +50,10 @@ vi.mock('@/cli/utilities/api/api-service', () => ({
createApplication = mockApiService.createApplication;
generateApplicationToken = mockApiService.generateApplicationToken;
renewApplicationToken = mockApiService.renewApplicationToken;
findApplicationRegistrationByUniversalIdentifier =
mockApiService.findApplicationRegistrationByUniversalIdentifier;
createApplicationRegistration =
mockApiService.createApplicationRegistration;
syncApplication = mockApiService.syncApplication;
uploadFile = mockApiService.uploadFile;
},
@@ -17,6 +17,10 @@ export class AppDevCommand {
await this.orchestrator?.close();
}
getOrchestrator(): DevModeOrchestrator | null {
return this.orchestrator;
}
async execute(options: AppDevOptions): Promise<void> {
const appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY;
@@ -1,13 +1,13 @@
import { type ApiResponse } from '@/cli/utilities/api/api-response-type';
import { ConfigService } from '@/cli/utilities/config/config-service';
import axios, { type AxiosInstance, type AxiosResponse } from 'axios';
import chalk from 'chalk';
import * as fs from 'fs';
import { createClient } from 'graphql-sse';
import { buildClientSchema, getIntrospectionQuery, printSchema } from 'graphql';
import { createClient } from 'graphql-sse';
import * as path from 'path';
import { type Manifest } from 'twenty-shared/application';
import { type FileFolder } from 'twenty-shared/types';
import { type ApiResponse } from '@/cli/utilities/api/api-response-type';
import { pascalCase } from 'twenty-shared/utils';
export class ApiService {
@@ -260,8 +260,126 @@ export class ApiService {
}
}
async findApplicationRegistrationByUniversalIdentifier(
universalIdentifier: string,
): Promise<
ApiResponse<{
id: string;
universalIdentifier: string;
name: string;
oAuthClientId: string;
} | null>
> {
try {
const query = `
query FindApplicationRegistrationByUniversalIdentifier($universalIdentifier: String!) {
findApplicationRegistrationByUniversalIdentifier(universalIdentifier: $universalIdentifier) {
id
universalIdentifier
name
oAuthClientId
}
}
`;
const response = await this.client.post(
'/metadata',
{
query,
variables: { universalIdentifier },
},
{
headers: {
'Content-Type': 'application/json',
Accept: '*/*',
},
},
);
if (response.data.errors) {
return {
success: false,
error: response.data.errors[0],
};
}
return {
success: true,
data: response.data.data
.findApplicationRegistrationByUniversalIdentifier,
};
} catch (error) {
return {
success: false,
error,
};
}
}
async createApplicationRegistration(input: {
name: string;
description?: string;
universalIdentifier: string;
}): Promise<
ApiResponse<{
applicationRegistration: {
id: string;
universalIdentifier: string;
oAuthClientId: string;
};
clientSecret: string;
}>
> {
try {
const mutation = `
mutation CreateApplicationRegistration($input: CreateApplicationRegistrationInput!) {
createApplicationRegistration(input: $input) {
applicationRegistration {
id
universalIdentifier
oAuthClientId
}
clientSecret
}
}
`;
const response = await this.client.post(
'/metadata',
{
query: mutation,
variables: { input },
},
{
headers: {
'Content-Type': 'application/json',
Accept: '*/*',
},
},
);
if (response.data.errors) {
return {
success: false,
error: response.data.errors[0],
};
}
return {
success: true,
data: response.data.data.createApplicationRegistration,
};
} catch (error) {
return {
success: false,
error,
};
}
}
async createApplication(
manifest: Manifest,
options?: { applicationRegistrationId?: string },
): Promise<ApiResponse<{ id: string; universalIdentifier: string }>> {
try {
const mutation = `
@@ -273,13 +391,19 @@ export class ApiService {
}
`;
const input: Record<string, string> = {
universalIdentifier: manifest.application.universalIdentifier,
name: manifest.application.displayName,
version: '0.0.1',
sourcePath: 'cli-sync',
};
if (options?.applicationRegistrationId) {
input.applicationRegistrationId = options.applicationRegistrationId;
}
const variables = {
input: {
universalIdentifier: manifest.application.universalIdentifier,
name: manifest.application.displayName,
version: '0.0.1',
sourcePath: 'cli-sync',
},
input,
};
const response: AxiosResponse = await this.client.post(
@@ -8,6 +8,8 @@ export type TwentyConfig = {
apiKey?: string;
applicationAccessToken?: string;
applicationRefreshToken?: string;
oauthClientId?: string;
oauthClientSecret?: string;
};
type PersistedConfig = TwentyConfig & {
@@ -6,6 +6,7 @@ import { BuildManifestOrchestratorStep } from '@/cli/utilities/dev/orchestrator/
import { CheckServerOrchestratorStep } from '@/cli/utilities/dev/orchestrator/steps/check-server-orchestrator-step';
import { EnsureValidTokensOrchestratorStep } from '@/cli/utilities/dev/orchestrator/steps/ensure-valid-tokens-orchestrator-step';
import { GenerateApiClientOrchestratorStep } from '@/cli/utilities/dev/orchestrator/steps/generate-api-client-orchestrator-step';
import { RegisterAppOrchestratorStep } from '@/cli/utilities/dev/orchestrator/steps/register-app-orchestrator-step';
import { ResolveApplicationOrchestratorStep } from '@/cli/utilities/dev/orchestrator/steps/resolve-application-orchestrator-step';
import {
StartWatchersOrchestratorStep,
@@ -34,6 +35,7 @@ export class DevModeOrchestrator {
private checkServerStep: CheckServerOrchestratorStep;
private ensureValidTokensStep: EnsureValidTokensOrchestratorStep;
private buildManifestStep: BuildManifestOrchestratorStep;
private registerAppStep: RegisterAppOrchestratorStep;
private resolveApplicationStep: ResolveApplicationOrchestratorStep;
private uploadFilesStep: UploadFilesOrchestratorStep;
private generateApiClientStep: GenerateApiClientOrchestratorStep;
@@ -59,6 +61,11 @@ export class DevModeOrchestrator {
configService,
});
this.buildManifestStep = new BuildManifestOrchestratorStep(stepDeps);
this.registerAppStep = new RegisterAppOrchestratorStep({
...stepDeps,
apiService,
configService,
});
this.resolveApplicationStep = new ResolveApplicationOrchestratorStep({
...stepDeps,
apiService,
@@ -216,8 +223,12 @@ export class DevModeOrchestrator {
}
private async initializePipeline(manifest: Manifest): Promise<boolean> {
const registerResult = await this.registerAppStep.execute({ manifest });
const resolveResult = await this.resolveApplicationStep.execute({
manifest,
applicationRegistrationId:
registerResult.applicationRegistrationId ?? undefined,
});
if (!resolveResult.applicationId) {
@@ -0,0 +1,120 @@
import { type ApiService } from '@/cli/utilities/api/api-service';
import { type ConfigService } from '@/cli/utilities/config/config-service';
import { type OrchestratorState } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
import { type Manifest } from 'twenty-shared/application';
export type RegisterAppOrchestratorStepOutput = {
applicationRegistrationId: string | null;
clientId: string | null;
};
export class RegisterAppOrchestratorStep {
private apiService: ApiService;
private configService: ConfigService;
private state: OrchestratorState;
private notify: () => void;
constructor({
apiService,
configService,
state,
notify,
}: {
apiService: ApiService;
configService: ConfigService;
state: OrchestratorState;
notify: () => void;
}) {
this.apiService = apiService;
this.configService = configService;
this.state = state;
this.notify = notify;
}
async execute(input: {
manifest: Manifest;
}): Promise<RegisterAppOrchestratorStepOutput> {
const universalIdentifier = input.manifest.application.universalIdentifier;
const findResult =
await this.apiService.findApplicationRegistrationByUniversalIdentifier(
universalIdentifier,
);
if (!findResult.success) {
this.state.applyStepEvents([
{
message: 'Failed to check app registration',
status: 'warning',
},
]);
this.notify();
return { applicationRegistrationId: null, clientId: null };
}
if (findResult.data) {
this.state.applyStepEvents([
{
message: `App registration found: ${findResult.data.name}`,
status: 'info',
},
]);
this.notify();
return {
applicationRegistrationId: findResult.data.id,
clientId: findResult.data.oAuthClientId,
};
}
const createResult = await this.apiService.createApplicationRegistration({
name: input.manifest.application.displayName,
description: input.manifest.application.description,
universalIdentifier,
});
if (!createResult.success || !createResult.data) {
this.state.applyStepEvents([
{
message: 'Failed to create app registration',
status: 'warning',
},
]);
this.notify();
return { applicationRegistrationId: null, clientId: null };
}
await this.configService.setConfig({
oauthClientId: createResult.data.applicationRegistration.oAuthClientId,
oauthClientSecret: createResult.data.clientSecret,
});
this.state.applyStepEvents([
{
message: `App registration created: ${input.manifest.application.displayName}`,
status: 'success',
},
{
message: `Client ID: ${createResult.data.applicationRegistration.oAuthClientId}`,
status: 'info',
},
{
message: `Client Secret: ${createResult.data.clientSecret}`,
status: 'warning',
},
{
message:
'Credentials saved to config. The secret will not be shown again.',
status: 'warning',
},
]);
this.notify();
return {
applicationRegistrationId: createResult.data.applicationRegistration.id,
clientId: createResult.data.applicationRegistration.oAuthClientId,
};
}
}
@@ -28,6 +28,7 @@ export class ResolveApplicationOrchestratorStep {
async execute(input: {
manifest: Manifest;
applicationRegistrationId?: string;
}): Promise<ResolveApplicationOrchestratorStepOutput> {
const step = this.state.steps.resolveApplication;
@@ -65,6 +66,7 @@ export class ResolveApplicationOrchestratorStep {
const createResult = await this.apiService.createApplication(
input.manifest,
{ applicationRegistrationId: input.applicationRegistrationId },
);
if (!createResult.success) {
@@ -0,0 +1,143 @@
import { type MigrationInterface, type QueryRunner } from 'typeorm';
export class CreateApplicationRegistration1772267875868
implements MigrationInterface
{
name = 'CreateApplicationRegistration1772267875868';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE "core"."applicationRegistration" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"universalIdentifier" uuid NOT NULL,
"name" text NOT NULL,
"description" text,
"logoUrl" text,
"author" text,
"oAuthClientId" text NOT NULL,
"oAuthClientSecretHash" text,
"oAuthRedirectUris" text[] NOT NULL DEFAULT '{}',
"oAuthScopes" text[] NOT NULL DEFAULT '{}',
"createdByUserId" uuid,
"websiteUrl" text,
"termsUrl" text,
"createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"deletedAt" TIMESTAMP WITH TIME ZONE,
CONSTRAINT "PK_application_registration" PRIMARY KEY ("id")
)
`);
await queryRunner.query(`
CREATE UNIQUE INDEX "IDX_APPLICATION_REGISTRATION_UNIVERSAL_IDENTIFIER_UNIQUE"
ON "core"."applicationRegistration" ("universalIdentifier")
WHERE "deletedAt" IS NULL
`);
await queryRunner.query(`
CREATE UNIQUE INDEX "IDX_APPLICATION_REGISTRATION_OAUTH_CLIENT_ID_UNIQUE"
ON "core"."applicationRegistration" ("oAuthClientId")
WHERE "deletedAt" IS NULL
`);
await queryRunner.query(`
CREATE INDEX "IDX_APPLICATION_REGISTRATION_CREATED_BY_USER_ID"
ON "core"."applicationRegistration" ("createdByUserId")
`);
await queryRunner.query(`
ALTER TABLE "core"."applicationRegistration"
ADD CONSTRAINT "FK_d5aa70ce34f5b8e51e5b0deafc2"
FOREIGN KEY ("createdByUserId") REFERENCES "core"."user"("id")
ON DELETE SET NULL ON UPDATE NO ACTION
`);
await queryRunner.query(`
CREATE TABLE "core"."applicationRegistrationVariable" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"key" text NOT NULL,
"encryptedValue" text NOT NULL DEFAULT '',
"description" text NOT NULL DEFAULT '',
"isSecret" boolean NOT NULL DEFAULT true,
"isRequired" boolean NOT NULL DEFAULT false,
"applicationRegistrationId" uuid NOT NULL,
"createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT "PK_application_registration_variable" PRIMARY KEY ("id")
)
`);
await queryRunner.query(`
CREATE INDEX "IDX_APP_REG_VAR_APP_REGISTRATION_ID"
ON "core"."applicationRegistrationVariable" ("applicationRegistrationId")
`);
await queryRunner.query(`
ALTER TABLE "core"."applicationRegistrationVariable"
ADD CONSTRAINT "IDX_APP_REG_VAR_KEY_APP_REGISTRATION_ID_UNIQUE"
UNIQUE ("key", "applicationRegistrationId")
`);
await queryRunner.query(`
ALTER TABLE "core"."applicationRegistrationVariable"
ADD CONSTRAINT "FK_067a6267789011853178a6ab57a"
FOREIGN KEY ("applicationRegistrationId") REFERENCES "core"."applicationRegistration"("id")
ON DELETE CASCADE ON UPDATE NO ACTION
`);
await queryRunner.query(
`ALTER TABLE "core"."application" ADD "applicationRegistrationId" uuid`,
);
await queryRunner.query(`
ALTER TABLE "core"."application"
ADD CONSTRAINT "FK_ca635da088fa8d5379ed268b55e"
FOREIGN KEY ("applicationRegistrationId") REFERENCES "core"."applicationRegistration"("id")
ON DELETE SET NULL ON UPDATE NO ACTION
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."application" DROP CONSTRAINT "FK_ca635da088fa8d5379ed268b55e"`,
);
await queryRunner.query(
`ALTER TABLE "core"."application" DROP COLUMN "applicationRegistrationId"`,
);
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistrationVariable" DROP CONSTRAINT "FK_067a6267789011853178a6ab57a"`,
);
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistrationVariable" DROP CONSTRAINT "IDX_APP_REG_VAR_KEY_APP_REGISTRATION_ID_UNIQUE"`,
);
await queryRunner.query(
`DROP INDEX "core"."IDX_APP_REG_VAR_APP_REGISTRATION_ID"`,
);
await queryRunner.query(
`DROP TABLE "core"."applicationRegistrationVariable"`,
);
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistration" DROP CONSTRAINT "FK_d5aa70ce34f5b8e51e5b0deafc2"`,
);
await queryRunner.query(
`DROP INDEX "core"."IDX_APPLICATION_REGISTRATION_CREATED_BY_USER_ID"`,
);
await queryRunner.query(
`DROP INDEX "core"."IDX_APPLICATION_REGISTRATION_OAUTH_CLIENT_ID_UNIQUE"`,
);
await queryRunner.query(
`DROP INDEX "core"."IDX_APPLICATION_REGISTRATION_UNIVERSAL_IDENTIFIER_UNIQUE"`,
);
await queryRunner.query(`DROP TABLE "core"."applicationRegistration"`);
}
}
@@ -7,9 +7,9 @@ import { PermissionFlagType } from 'twenty-shared/constants';
import { AdminPanelHealthService } from 'src/engine/core-modules/admin-panel/admin-panel-health.service';
import { AdminPanelQueueService } from 'src/engine/core-modules/admin-panel/admin-panel-queue.service';
import { AdminPanelService } from 'src/engine/core-modules/admin-panel/admin-panel.service';
import { AdminAIModelsOutput } from 'src/engine/core-modules/client-config/client-config.entity';
import { AdminAIModelsDTO } from 'src/engine/core-modules/client-config/client-config.entity';
import { ConfigVariableDTO } from 'src/engine/core-modules/admin-panel/dtos/config-variable.dto';
import { ConfigVariablesOutput } from 'src/engine/core-modules/admin-panel/dtos/config-variables.output';
import { ConfigVariablesDTO } from 'src/engine/core-modules/admin-panel/dtos/config-variables.dto';
import { DeleteJobsResponseDTO } from 'src/engine/core-modules/admin-panel/dtos/delete-jobs-response.dto';
import { QueueJobsResponseDTO } from 'src/engine/core-modules/admin-panel/dtos/queue-jobs-response.dto';
import { RetryJobsResponseDTO } from 'src/engine/core-modules/admin-panel/dtos/retry-jobs-response.dto';
@@ -95,8 +95,8 @@ export class AdminPanelResolver {
}
@UseGuards(AdminPanelGuard)
@Query(() => ConfigVariablesOutput)
async getConfigVariablesGrouped(): Promise<ConfigVariablesOutput> {
@Query(() => ConfigVariablesDTO)
async getConfigVariablesGrouped(): Promise<ConfigVariablesDTO> {
return this.adminService.getConfigVariablesGrouped();
}
@@ -142,8 +142,8 @@ export class AdminPanelResolver {
}
@UseGuards(AdminPanelGuard)
@Query(() => AdminAIModelsOutput)
async getAdminAiModels(): Promise<AdminAIModelsOutput> {
@Query(() => AdminAIModelsDTO)
async getAdminAiModels(): Promise<AdminAIModelsDTO> {
const models = this.aiModelRegistryService
.getAllModelsWithStatus()
.map(({ modelConfig, isAvailable, isAdminEnabled }) => ({
@@ -9,7 +9,7 @@ import * as z from 'zod';
import { type ConfigVariableDTO } from 'src/engine/core-modules/admin-panel/dtos/config-variable.dto';
import { type ConfigVariablesGroupDataDTO } from 'src/engine/core-modules/admin-panel/dtos/config-variables-group.dto';
import { type ConfigVariablesOutput } from 'src/engine/core-modules/admin-panel/dtos/config-variables.output';
import { type ConfigVariablesDTO } from 'src/engine/core-modules/admin-panel/dtos/config-variables.dto';
import { type UserLookup } from 'src/engine/core-modules/admin-panel/dtos/user-lookup.dto';
import { type VersionInfoDTO } from 'src/engine/core-modules/admin-panel/dtos/version-info.dto';
import {
@@ -112,7 +112,7 @@ export class AdminPanelService {
};
}
getConfigVariablesGrouped(): ConfigVariablesOutput {
getConfigVariablesGrouped(): ConfigVariablesDTO {
const rawEnvVars = this.twentyConfigService.getAll();
const groupedData = new Map<ConfigVariablesGroup, ConfigVariableDTO[]>();
@@ -2,8 +2,8 @@ import { Field, ObjectType } from '@nestjs/graphql';
import { ConfigVariablesGroupDataDTO } from 'src/engine/core-modules/admin-panel/dtos/config-variables-group.dto';
@ObjectType('ConfigVariablesOutput')
export class ConfigVariablesOutput {
@ObjectType('ConfigVariables')
export class ConfigVariablesDTO {
@Field(() => [ConfigVariablesGroupDataDTO])
groups: ConfigVariablesGroupDataDTO[];
}
@@ -3,8 +3,8 @@ import { Field, ObjectType } from '@nestjs/graphql';
import { AuthToken } from 'src/engine/core-modules/auth/dto/auth-token.dto';
import { WorkspaceUrlsAndIdDTO } from 'src/engine/core-modules/workspace/dtos/workspace-subdomain-id.dto';
@ObjectType('ImpersonateOutput')
export class ImpersonateOutput {
@ObjectType('Impersonate')
export class ImpersonateDTO {
@Field(() => AuthToken)
loginToken: AuthToken;
@@ -1,7 +1,7 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { FeatureFlagDTO } from 'src/engine/core-modules/feature-flag/dtos/feature-flag.dto';
import { WorkspaceUrlsDTO } from 'src/engine/core-modules/workspace/dtos/workspace-urls.dto';
@ObjectType('UserInfo')
@@ -42,8 +42,8 @@ class WorkspaceInfoDTO {
@Field(() => [UserInfoDTO])
users: UserInfoDTO[];
@Field(() => [FeatureFlagEntity])
featureFlags: FeatureFlagEntity[];
@Field(() => [FeatureFlagDTO])
featureFlags: FeatureFlagDTO[];
}
@ObjectType('UserLookup')
@@ -6,10 +6,10 @@ import { PermissionFlagType } from 'twenty-shared/constants';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
import { CreateApiKeyInput } from 'src/engine/core-modules/api-key/dtos/create-api-key.dto';
import { GetApiKeyInput } from 'src/engine/core-modules/api-key/dtos/get-api-key.dto';
import { RevokeApiKeyInput } from 'src/engine/core-modules/api-key/dtos/revoke-api-key.dto';
import { UpdateApiKeyInput } from 'src/engine/core-modules/api-key/dtos/update-api-key.dto';
import { CreateApiKeyInput } from 'src/engine/core-modules/api-key/dtos/create-api-key.input';
import { GetApiKeyInput } from 'src/engine/core-modules/api-key/dtos/get-api-key.input';
import { RevokeApiKeyInput } from 'src/engine/core-modules/api-key/dtos/revoke-api-key.input';
import { UpdateApiKeyInput } from 'src/engine/core-modules/api-key/dtos/update-api-key.input';
import {
ApiKeyException,
ApiKeyExceptionCode,
@@ -15,8 +15,8 @@ import { PermissionFlagType } from 'twenty-shared/constants';
import { RestApiExceptionFilter } from 'src/engine/api/rest/rest-api-exception.filter';
import { type ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
import { CreateApiKeyInput } from 'src/engine/core-modules/api-key/dtos/create-api-key.dto';
import { UpdateApiKeyInput } from 'src/engine/core-modules/api-key/dtos/update-api-key.dto';
import { CreateApiKeyInput } from 'src/engine/core-modules/api-key/dtos/create-api-key.input';
import { UpdateApiKeyInput } from 'src/engine/core-modules/api-key/dtos/update-api-key.input';
import { ApiKeyService } from 'src/engine/core-modules/api-key/services/api-key.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
@@ -89,5 +89,5 @@ export class AppTokenEntity {
}
@Column({ nullable: true, type: 'jsonb' })
context: { email: string } | null;
context: { email?: string; redirectUri?: string } | null;
}
@@ -0,0 +1,25 @@
import {
ALL_OAUTH_SCOPES,
OAUTH_SCOPE_DESCRIPTIONS,
OAUTH_SCOPES,
} from 'src/engine/core-modules/application-registration/constants/oauth-scopes';
describe('OAuth Scopes', () => {
it('should have all scopes defined', () => {
expect(ALL_OAUTH_SCOPES).toContain('api');
expect(ALL_OAUTH_SCOPES).toContain('profile');
expect(ALL_OAUTH_SCOPES).toHaveLength(2);
});
it('should have descriptions for all scopes', () => {
for (const scope of ALL_OAUTH_SCOPES) {
expect(OAUTH_SCOPE_DESCRIPTIONS[scope]).toBeDefined();
expect(OAUTH_SCOPE_DESCRIPTIONS[scope].length).toBeGreaterThan(0);
}
});
it('should have consistent keys and values', () => {
expect(OAUTH_SCOPES.API).toBe('api');
expect(OAUTH_SCOPES.PROFILE).toBe('profile');
});
});
@@ -0,0 +1,74 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { IDField } from '@ptc-org/nestjs-query-graphql';
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
type Relation,
Unique,
UpdateDateColumn,
} from 'typeorm';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application-registration/application-registration.entity';
@Entity({ name: 'applicationRegistrationVariable', schema: 'core' })
@ObjectType('ApplicationRegistrationVariable')
@Unique('IDX_APP_REG_VAR_KEY_APP_REGISTRATION_ID_UNIQUE', [
'key',
'applicationRegistrationId',
])
@Index('IDX_APP_REG_VAR_APP_REGISTRATION_ID', ['applicationRegistrationId'])
export class ApplicationRegistrationVariableEntity {
@IDField(() => UUIDScalarType)
@PrimaryGeneratedColumn('uuid')
id: string;
@Field()
@Column({ nullable: false, type: 'text' })
key: string;
@Column({ nullable: false, type: 'text', default: '' })
encryptedValue: string;
@Field()
@Column({ nullable: false, type: 'text', default: '' })
description: string;
@Field()
@Column({ nullable: false, type: 'boolean', default: true })
isSecret: boolean;
@Field()
@Column({ nullable: false, type: 'boolean', default: false })
isRequired: boolean;
@Field()
get isFilled(): boolean {
return this.encryptedValue !== '';
}
@Column({ nullable: false, type: 'uuid' })
applicationRegistrationId: string;
@ManyToOne(
() => ApplicationRegistrationEntity,
(applicationRegistration) => applicationRegistration.variables,
{ onDelete: 'CASCADE', nullable: false },
)
@JoinColumn({ name: 'applicationRegistrationId' })
applicationRegistration: Relation<ApplicationRegistrationEntity>;
@Field()
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@Field()
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
}
@@ -0,0 +1,165 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { type ServerVariables } from 'twenty-shared/application';
import { isDefined } from 'twenty-shared/utils';
import { In, Not, type Repository } from 'typeorm';
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application-registration/application-registration-variable.entity';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application-registration/application-registration.entity';
import {
ApplicationRegistrationException,
ApplicationRegistrationExceptionCode,
} from 'src/engine/core-modules/application-registration/application-registration.exception';
import { type CreateApplicationRegistrationVariableInput } from 'src/engine/core-modules/application-registration/dtos/create-application-registration-variable.input';
import { type UpdateApplicationRegistrationVariableInput } from 'src/engine/core-modules/application-registration/dtos/update-application-registration-variable.input';
import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
@Injectable()
export class ApplicationRegistrationVariableService {
constructor(
@InjectRepository(ApplicationRegistrationVariableEntity)
private readonly variableRepository: Repository<ApplicationRegistrationVariableEntity>,
@InjectRepository(ApplicationRegistrationEntity)
private readonly applicationRegistrationRepository: Repository<ApplicationRegistrationEntity>,
private readonly encryptionService: SecretEncryptionService,
) {}
async findVariables(
applicationRegistrationId: string,
): Promise<ApplicationRegistrationVariableEntity[]> {
return this.variableRepository.find({
where: { applicationRegistrationId },
order: { key: 'ASC' },
});
}
async createVariable(
input: CreateApplicationRegistrationVariableInput,
): Promise<ApplicationRegistrationVariableEntity> {
await this.assertRegistrationExists(input.applicationRegistrationId);
const encryptedValue = this.encryptionService.encrypt(input.value);
const variable = this.variableRepository.create({
applicationRegistrationId: input.applicationRegistrationId,
key: input.key,
encryptedValue,
description: input.description ?? '',
isSecret: input.isSecret ?? true,
});
return this.variableRepository.save(variable);
}
async updateVariable(
input: UpdateApplicationRegistrationVariableInput,
): Promise<ApplicationRegistrationVariableEntity> {
const { id, update } = input;
const variable = await this.variableRepository.findOne({
where: { id },
});
if (!variable) {
throw new ApplicationRegistrationException(
`Variable with id ${id} not found`,
ApplicationRegistrationExceptionCode.VARIABLE_NOT_FOUND,
);
}
const updateData: Record<string, unknown> = {};
if (isDefined(update.value)) {
updateData.encryptedValue = this.encryptionService.encrypt(update.value);
}
if (isDefined(update.description)) {
updateData.description = update.description;
}
if (Object.keys(updateData).length > 0) {
await this.variableRepository.update(id, updateData);
}
return this.variableRepository.findOneOrFail({ where: { id } });
}
async deleteVariable(id: string): Promise<boolean> {
const variable = await this.variableRepository.findOne({
where: { id },
});
if (!variable) {
throw new ApplicationRegistrationException(
`Variable with id ${id} not found`,
ApplicationRegistrationExceptionCode.VARIABLE_NOT_FOUND,
);
}
await this.variableRepository.delete(id);
return true;
}
// Syncs variable schemas from manifest: creates missing, updates metadata, removes stale
async syncVariableSchemas(
applicationRegistrationId: string,
serverVariables: ServerVariables,
): Promise<void> {
const declaredKeys = Object.keys(serverVariables);
const existingVariables = await this.variableRepository.find({
where: { applicationRegistrationId },
});
const existingByKey = new Map(
existingVariables.map((variable) => [variable.key, variable]),
);
for (const [key, schema] of Object.entries(serverVariables)) {
const existing = existingByKey.get(key);
if (existing) {
await this.variableRepository.update(existing.id, {
description: schema.description ?? '',
isSecret: schema.isSecret ?? true,
isRequired: schema.isRequired ?? false,
});
} else {
await this.variableRepository.save(
this.variableRepository.create({
applicationRegistrationId,
key,
encryptedValue: '',
description: schema.description ?? '',
isSecret: schema.isSecret ?? true,
isRequired: schema.isRequired ?? false,
}),
);
}
}
if (declaredKeys.length > 0) {
await this.variableRepository.delete({
applicationRegistrationId,
key: Not(In(declaredKeys)),
});
} else {
await this.variableRepository.delete({ applicationRegistrationId });
}
}
private async assertRegistrationExists(id: string): Promise<void> {
const registration = await this.applicationRegistrationRepository.findOne({
where: { id },
});
if (!registration) {
throw new ApplicationRegistrationException(
`Application registration with id ${id} not found`,
ApplicationRegistrationExceptionCode.APPLICATION_REGISTRATION_NOT_FOUND,
);
}
}
}
@@ -0,0 +1,113 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { IDField } from '@ptc-org/nestjs-query-graphql';
import {
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
type Relation,
UpdateDateColumn,
} from 'typeorm';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application-registration/application-registration-variable.entity';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
@Entity({ name: 'applicationRegistration', schema: 'core' })
@ObjectType('ApplicationRegistration')
@Index(
'IDX_APPLICATION_REGISTRATION_UNIVERSAL_IDENTIFIER_UNIQUE',
['universalIdentifier'],
{
unique: true,
where: '"deletedAt" IS NULL',
},
)
@Index(
'IDX_APPLICATION_REGISTRATION_OAUTH_CLIENT_ID_UNIQUE',
['oAuthClientId'],
{
unique: true,
where: '"deletedAt" IS NULL',
},
)
@Index('IDX_APPLICATION_REGISTRATION_CREATED_BY_USER_ID', ['createdByUserId'])
export class ApplicationRegistrationEntity {
@IDField(() => UUIDScalarType)
@PrimaryGeneratedColumn('uuid')
id: string;
@Field()
@Column({ nullable: false, type: 'uuid' })
universalIdentifier: string;
@Field()
@Column({ nullable: false, type: 'text' })
name: string;
@Field(() => String, { nullable: true })
@Column({ nullable: true, type: 'text' })
description: string | null;
@Field(() => String, { nullable: true })
@Column({ nullable: true, type: 'text' })
logoUrl: string | null;
@Field(() => String, { nullable: true })
@Column({ nullable: true, type: 'text' })
author: string | null;
@Field()
@Column({ nullable: false, type: 'text' })
oAuthClientId: string;
@Column({ nullable: true, type: 'text' })
oAuthClientSecretHash: string | null;
@Field(() => [String])
@Column({ type: 'text', array: true, default: '{}' })
oAuthRedirectUris: string[];
@Field(() => [String])
@Column({ type: 'text', array: true, default: '{}' })
oAuthScopes: string[];
@Column({ nullable: true, type: 'uuid' })
createdByUserId: string | null;
@ManyToOne(() => UserEntity, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'createdByUserId' })
createdByUser: Relation<UserEntity> | null;
@Field(() => String, { nullable: true })
@Column({ nullable: true, type: 'text' })
websiteUrl: string | null;
@Field(() => String, { nullable: true })
@Column({ nullable: true, type: 'text' })
termsUrl: string | null;
@OneToMany(
() => ApplicationRegistrationVariableEntity,
(variable) => variable.applicationRegistration,
{ onDelete: 'CASCADE' },
)
variables: Relation<ApplicationRegistrationVariableEntity[]>;
@Field()
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@Field()
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
@DeleteDateColumn({ type: 'timestamptz' })
deletedAt: Date | null;
}
@@ -0,0 +1,45 @@
import { type MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { assertUnreachable } from 'twenty-shared/utils';
import { CustomException } from 'src/utils/custom-exception';
export enum ApplicationRegistrationExceptionCode {
APPLICATION_REGISTRATION_NOT_FOUND = 'APPLICATION_REGISTRATION_NOT_FOUND',
UNIVERSAL_IDENTIFIER_ALREADY_CLAIMED = 'UNIVERSAL_IDENTIFIER_ALREADY_CLAIMED',
INVALID_SCOPE = 'INVALID_SCOPE',
INVALID_REDIRECT_URI = 'INVALID_REDIRECT_URI',
VARIABLE_NOT_FOUND = 'VARIABLE_NOT_FOUND',
}
const getExceptionUserFriendlyMessage = (
code: ApplicationRegistrationExceptionCode,
) => {
switch (code) {
case ApplicationRegistrationExceptionCode.APPLICATION_REGISTRATION_NOT_FOUND:
return msg`Application registration not found.`;
case ApplicationRegistrationExceptionCode.UNIVERSAL_IDENTIFIER_ALREADY_CLAIMED:
return msg`This universal identifier is already claimed by another registration.`;
case ApplicationRegistrationExceptionCode.INVALID_SCOPE:
return msg`One or more requested scopes are invalid.`;
case ApplicationRegistrationExceptionCode.INVALID_REDIRECT_URI:
return msg`One or more redirect URIs are invalid.`;
case ApplicationRegistrationExceptionCode.VARIABLE_NOT_FOUND:
return msg`Application registration variable not found.`;
default:
assertUnreachable(code);
}
};
export class ApplicationRegistrationException extends CustomException<ApplicationRegistrationExceptionCode> {
constructor(
message: string,
code: ApplicationRegistrationExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {},
) {
super(message, code, {
userFriendlyMessage:
userFriendlyMessage ?? getExceptionUserFriendlyMessage(code),
});
}
}
@@ -0,0 +1,46 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application-registration/application-registration-variable.entity';
import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application-registration/application-registration-variable.service';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application-registration/application-registration.entity';
import { ApplicationRegistrationResolver } from 'src/engine/core-modules/application-registration/application-registration.resolver';
import { ApplicationRegistrationService } from 'src/engine/core-modules/application-registration/application-registration.service';
import { OAuthDiscoveryController } from 'src/engine/core-modules/application-registration/controllers/oauth-discovery.controller';
import { OAuthTokenController } from 'src/engine/core-modules/application-registration/controllers/oauth-token.controller';
import { OAuthService } from 'src/engine/core-modules/application-registration/oauth.service';
import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { SecretEncryptionModule } from 'src/engine/core-modules/secret-encryption/secret-encryption.module';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
@Module({
imports: [
TypeOrmModule.forFeature([
ApplicationRegistrationEntity,
ApplicationRegistrationVariableEntity,
ApplicationEntity,
AppTokenEntity,
UserWorkspaceEntity,
]),
SecretEncryptionModule,
PermissionsModule,
TokenModule,
ApplicationModule,
],
controllers: [OAuthTokenController, OAuthDiscoveryController],
providers: [
ApplicationRegistrationService,
ApplicationRegistrationVariableService,
ApplicationRegistrationResolver,
OAuthService,
],
exports: [
ApplicationRegistrationService,
ApplicationRegistrationVariableService,
],
})
export class ApplicationRegistrationModule {}
@@ -0,0 +1,181 @@
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
import { Args, Mutation, Query } from '@nestjs/graphql';
import { PermissionFlagType } from 'twenty-shared/constants';
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application-registration/application-registration-variable.entity';
import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application-registration/application-registration-variable.service';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application-registration/application-registration.entity';
import { ApplicationRegistrationService } from 'src/engine/core-modules/application-registration/application-registration.service';
import { ApplicationRegistrationStatsDTO } from 'src/engine/core-modules/application-registration/dtos/application-registration-stats.dto';
import { CreateApplicationRegistrationInput } from 'src/engine/core-modules/application-registration/dtos/create-application-registration.input';
import { CreateApplicationRegistrationDTO } from 'src/engine/core-modules/application-registration/dtos/create-application-registration.dto';
import { CreateApplicationRegistrationVariableInput } from 'src/engine/core-modules/application-registration/dtos/create-application-registration-variable.input';
import { RotateClientSecretDTO } from 'src/engine/core-modules/application-registration/dtos/rotate-client-secret.dto';
import { UpdateApplicationRegistrationInput } from 'src/engine/core-modules/application-registration/dtos/update-application-registration.input';
import { UpdateApplicationRegistrationVariableInput } from 'src/engine/core-modules/application-registration/dtos/update-application-registration-variable.input';
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
@UsePipes(ResolverValidationPipe)
@MetadataResolver()
@UseFilters(
AuthGraphqlApiExceptionFilter,
PreventNestToAutoLogGraphqlErrorsFilter,
)
export class ApplicationRegistrationResolver {
constructor(
private readonly applicationRegistrationService: ApplicationRegistrationService,
private readonly applicationRegistrationVariableService: ApplicationRegistrationVariableService,
) {}
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
@Query(() => ApplicationRegistrationEntity, { nullable: true })
async findApplicationRegistrationByClientId(
@Args('clientId') clientId: string,
): Promise<ApplicationRegistrationEntity | null> {
return this.applicationRegistrationService.findOneByClientId(clientId);
}
@UseGuards(WorkspaceAuthGuard, NoPermissionGuard)
@Query(() => ApplicationRegistrationEntity, { nullable: true })
async findApplicationRegistrationByUniversalIdentifier(
@Args('universalIdentifier') universalIdentifier: string,
): Promise<ApplicationRegistrationEntity | null> {
return this.applicationRegistrationService.findOneByUniversalIdentifier(
universalIdentifier,
);
}
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
)
@Query(() => [ApplicationRegistrationEntity])
async findManyApplicationRegistrations(): Promise<
ApplicationRegistrationEntity[]
> {
return this.applicationRegistrationService.findMany();
}
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
)
@Query(() => ApplicationRegistrationEntity)
async findOneApplicationRegistration(
@Args('id') id: string,
): Promise<ApplicationRegistrationEntity> {
return this.applicationRegistrationService.findOneById(id);
}
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
)
@Query(() => ApplicationRegistrationStatsDTO)
async findApplicationRegistrationStats(
@Args('id') id: string,
): Promise<ApplicationRegistrationStatsDTO> {
return this.applicationRegistrationService.getStats(id);
}
@UseGuards(WorkspaceAuthGuard, NoPermissionGuard)
@Mutation(() => CreateApplicationRegistrationDTO)
async createApplicationRegistration(
@Args('input') input: CreateApplicationRegistrationInput,
@AuthUser({ allowUndefined: true }) user: UserEntity | undefined,
): Promise<CreateApplicationRegistrationDTO> {
return this.applicationRegistrationService.create(input, user?.id ?? null);
}
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
)
@Mutation(() => ApplicationRegistrationEntity)
async updateApplicationRegistration(
@Args('input') input: UpdateApplicationRegistrationInput,
): Promise<ApplicationRegistrationEntity> {
return this.applicationRegistrationService.update(input);
}
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
)
@Mutation(() => Boolean)
async deleteApplicationRegistration(
@Args('id') id: string,
): Promise<boolean> {
return this.applicationRegistrationService.delete(id);
}
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
)
@Mutation(() => RotateClientSecretDTO)
async rotateApplicationRegistrationClientSecret(
@Args('id') id: string,
): Promise<RotateClientSecretDTO> {
const clientSecret =
await this.applicationRegistrationService.rotateClientSecret(id);
return { clientSecret };
}
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
)
@Query(() => [ApplicationRegistrationVariableEntity])
async findApplicationRegistrationVariables(
@Args('applicationRegistrationId') applicationRegistrationId: string,
): Promise<ApplicationRegistrationVariableEntity[]> {
return this.applicationRegistrationVariableService.findVariables(
applicationRegistrationId,
);
}
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
)
@Mutation(() => ApplicationRegistrationVariableEntity)
async createApplicationRegistrationVariable(
@Args('input') input: CreateApplicationRegistrationVariableInput,
): Promise<ApplicationRegistrationVariableEntity> {
return this.applicationRegistrationVariableService.createVariable(input);
}
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
)
@Mutation(() => ApplicationRegistrationVariableEntity)
async updateApplicationRegistrationVariable(
@Args('input') input: UpdateApplicationRegistrationVariableInput,
): Promise<ApplicationRegistrationVariableEntity> {
return this.applicationRegistrationVariableService.updateVariable(input);
}
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
)
@Mutation(() => Boolean)
async deleteApplicationRegistrationVariable(
@Args('id') id: string,
): Promise<boolean> {
return this.applicationRegistrationVariableService.deleteVariable(id);
}
}
@@ -0,0 +1,264 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import crypto from 'crypto';
import * as bcrypt from 'bcrypt';
import { isDefined } from 'twenty-shared/utils';
import { type Repository } from 'typeorm';
import { v4 } from 'uuid';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application-registration/application-registration.entity';
import {
ApplicationRegistrationException,
ApplicationRegistrationExceptionCode,
} from 'src/engine/core-modules/application-registration/application-registration.exception';
import { ALL_OAUTH_SCOPES } from 'src/engine/core-modules/application-registration/constants/oauth-scopes';
import { type ApplicationRegistrationStatsDTO } from 'src/engine/core-modules/application-registration/dtos/application-registration-stats.dto';
import { type CreateApplicationRegistrationInput } from 'src/engine/core-modules/application-registration/dtos/create-application-registration.input';
import { type UpdateApplicationRegistrationInput } from 'src/engine/core-modules/application-registration/dtos/update-application-registration.input';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { validateRedirectUri } from 'src/engine/core-modules/auth/utils/validate-redirect-uri.util';
const BCRYPT_SALT_ROUNDS = 10;
@Injectable()
export class ApplicationRegistrationService {
constructor(
@InjectRepository(ApplicationRegistrationEntity)
private readonly applicationRegistrationRepository: Repository<ApplicationRegistrationEntity>,
@InjectRepository(ApplicationEntity)
private readonly applicationRepository: Repository<ApplicationEntity>,
) {}
async findMany(): Promise<ApplicationRegistrationEntity[]> {
return this.applicationRegistrationRepository.find({
order: { createdAt: 'DESC' },
});
}
async findOneById(id: string): Promise<ApplicationRegistrationEntity> {
const registration = await this.applicationRegistrationRepository.findOne({
where: { id },
});
if (!registration) {
throw new ApplicationRegistrationException(
`Application registration with id ${id} not found`,
ApplicationRegistrationExceptionCode.APPLICATION_REGISTRATION_NOT_FOUND,
);
}
return registration;
}
async findOneByClientId(
clientId: string,
): Promise<ApplicationRegistrationEntity | null> {
return this.applicationRegistrationRepository.findOne({
where: { oAuthClientId: clientId },
});
}
async findOneByUniversalIdentifier(
universalIdentifier: string,
): Promise<ApplicationRegistrationEntity | null> {
return this.applicationRegistrationRepository.findOne({
where: { universalIdentifier },
});
}
async create(
input: CreateApplicationRegistrationInput,
createdByUserId: string | null,
): Promise<{
applicationRegistration: ApplicationRegistrationEntity;
clientSecret: string;
}> {
const universalIdentifier = input.universalIdentifier ?? v4();
const existingByUid =
await this.findOneByUniversalIdentifier(universalIdentifier);
if (existingByUid) {
throw new ApplicationRegistrationException(
`Universal identifier ${universalIdentifier} is already claimed`,
ApplicationRegistrationExceptionCode.UNIVERSAL_IDENTIFIER_ALREADY_CLAIMED,
);
}
if (isDefined(input.oAuthRedirectUris)) {
this.validateRedirectUris(input.oAuthRedirectUris);
}
if (isDefined(input.oAuthScopes)) {
this.validateScopes(input.oAuthScopes);
}
const clientId = v4();
const { clientSecret, clientSecretHash } =
await this.generateClientSecret();
const applicationRegistration =
this.applicationRegistrationRepository.create({
universalIdentifier,
name: input.name,
description: input.description ?? null,
logoUrl: input.logoUrl ?? null,
author: input.author ?? null,
oAuthClientId: clientId,
oAuthClientSecretHash: clientSecretHash,
oAuthRedirectUris: input.oAuthRedirectUris ?? [],
oAuthScopes: input.oAuthScopes ?? [],
createdByUserId,
websiteUrl: input.websiteUrl ?? null,
termsUrl: input.termsUrl ?? null,
});
const saved = await this.applicationRegistrationRepository.save(
applicationRegistration,
);
return { applicationRegistration: saved, clientSecret };
}
async update(
input: UpdateApplicationRegistrationInput,
): Promise<ApplicationRegistrationEntity> {
const { id, update } = input;
await this.findOneById(id);
if (isDefined(update.oAuthRedirectUris)) {
this.validateRedirectUris(update.oAuthRedirectUris);
}
if (isDefined(update.oAuthScopes)) {
this.validateScopes(update.oAuthScopes);
}
const updateData: Record<string, unknown> = {};
if (isDefined(update.name)) updateData.name = update.name;
if (isDefined(update.description))
updateData.description = update.description;
if (isDefined(update.logoUrl)) updateData.logoUrl = update.logoUrl;
if (isDefined(update.author)) updateData.author = update.author;
if (isDefined(update.oAuthRedirectUris))
updateData.oAuthRedirectUris = update.oAuthRedirectUris;
if (isDefined(update.oAuthScopes))
updateData.oAuthScopes = update.oAuthScopes;
if (isDefined(update.websiteUrl)) updateData.websiteUrl = update.websiteUrl;
if (isDefined(update.termsUrl)) updateData.termsUrl = update.termsUrl;
if (Object.keys(updateData).length > 0) {
await this.applicationRegistrationRepository.update(id, updateData);
}
return this.findOneById(id);
}
async delete(id: string): Promise<boolean> {
await this.findOneById(id);
await this.applicationRegistrationRepository.softDelete(id);
return true;
}
async rotateClientSecret(id: string): Promise<string> {
await this.findOneById(id);
const { clientSecret, clientSecretHash } =
await this.generateClientSecret();
await this.applicationRegistrationRepository.update(id, {
oAuthClientSecretHash: clientSecretHash,
});
return clientSecret;
}
async verifyClientSecret(
registration: ApplicationRegistrationEntity,
clientSecret: string,
): Promise<boolean> {
if (!registration.oAuthClientSecretHash) {
return false;
}
return bcrypt.compare(clientSecret, registration.oAuthClientSecretHash);
}
async getStats(
applicationRegistrationId: string,
): Promise<ApplicationRegistrationStatsDTO> {
await this.findOneById(applicationRegistrationId);
const versionDistribution: { version: string; count: number }[] =
await this.applicationRepository
.createQueryBuilder('application')
.select("COALESCE(application.version, 'unknown')", 'version')
.addSelect('COUNT(*)::int', 'count')
.where(
'application."applicationRegistrationId" = :applicationRegistrationId',
{ applicationRegistrationId },
)
.andWhere('application."deletedAt" IS NULL')
.groupBy('version')
.orderBy('count', 'DESC')
.getRawMany();
const activeInstalls = versionDistribution.reduce(
(sum, entry) => sum + entry.count,
0,
);
const mostInstalledVersion = versionDistribution[0]?.version ?? null;
return {
activeInstalls,
mostInstalledVersion,
versionDistribution,
};
}
private async generateClientSecret(): Promise<{
clientSecret: string;
clientSecretHash: string;
}> {
const clientSecret = crypto.randomBytes(32).toString('hex');
const clientSecretHash = await bcrypt.hash(
clientSecret,
BCRYPT_SALT_ROUNDS,
);
return { clientSecret, clientSecretHash };
}
private validateRedirectUris(uris: string[]): void {
for (const uri of uris) {
const result = validateRedirectUri(uri);
if (!result.valid) {
throw new ApplicationRegistrationException(
result.reason,
ApplicationRegistrationExceptionCode.INVALID_REDIRECT_URI,
);
}
}
}
private validateScopes(scopes: string[]): void {
const validScopes: readonly string[] = ALL_OAUTH_SCOPES;
const invalidScopes = scopes.filter(
(scope) => !validScopes.includes(scope),
);
if (invalidScopes.length > 0) {
throw new ApplicationRegistrationException(
`Invalid scopes: ${invalidScopes.join(', ')}`,
ApplicationRegistrationExceptionCode.INVALID_SCOPE,
);
}
}
}
@@ -0,0 +1,16 @@
// Scopes are a thin consent boundary shown to the user during OAuth authorization.
// Actual permissions are enforced by the role assigned to the application at the
// workspace level (object, field, and row-level permissions).
export const OAUTH_SCOPES = {
API: 'api',
PROFILE: 'profile',
} as const;
export type OAuthScope = (typeof OAUTH_SCOPES)[keyof typeof OAUTH_SCOPES];
export const ALL_OAUTH_SCOPES: OAuthScope[] = Object.values(OAUTH_SCOPES);
export const OAUTH_SCOPE_DESCRIPTIONS: Record<OAuthScope, string> = {
[OAUTH_SCOPES.API]: 'Access workspace data according to the assigned role',
[OAUTH_SCOPES.PROFILE]: "Read the authenticated user's profile",
};
@@ -0,0 +1,32 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { ALL_OAUTH_SCOPES } from 'src/engine/core-modules/application-registration/constants/oauth-scopes';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
@Controller('.well-known')
export class OAuthDiscoveryController {
constructor(private readonly twentyConfigService: TwentyConfigService) {}
@Get('oauth-authorization-server')
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
getAuthorizationServerMetadata() {
const serverUrl = this.twentyConfigService.get('SERVER_URL');
return {
issuer: serverUrl,
authorization_endpoint: `${serverUrl}/authorize`,
token_endpoint: `${serverUrl}/oauth/token`,
scopes_supported: ALL_OAUTH_SCOPES,
response_types_supported: ['code'],
grant_types_supported: [
'authorization_code',
'client_credentials',
'refresh_token',
],
code_challenge_methods_supported: ['S256'],
token_endpoint_auth_methods_supported: ['client_secret_post', 'none'],
};
}
}
@@ -0,0 +1,75 @@
import {
Body,
Controller,
Post,
Res,
UseFilters,
UseGuards,
UsePipes,
ValidationPipe,
} from '@nestjs/common';
import { type Response } from 'express';
import { OAuthTokenInput } from 'src/engine/core-modules/application-registration/dtos/oauth-token.input';
import { OAuthService } from 'src/engine/core-modules/application-registration/oauth.service';
import { OAuthErrorResponse } from 'src/engine/core-modules/application-registration/types/oauth-error-response.type';
import { OAuthTokenResponse } from 'src/engine/core-modules/application-registration/types/oauth-token-response.type';
import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
@Controller('oauth')
@UseFilters(AuthRestApiExceptionFilter)
export class OAuthTokenController {
constructor(private readonly oauthService: OAuthService) {}
@Post('token')
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
@UsePipes(new ValidationPipe())
async token(
@Body() body: OAuthTokenInput,
@Res({ passthrough: true }) res: Response,
) {
let result: OAuthTokenResponse | OAuthErrorResponse;
switch (body.grant_type) {
case 'authorization_code':
result = await this.oauthService.exchangeAuthorizationCode({
authorizationCode: body.code ?? '',
clientId: body.client_id ?? '',
clientSecret: body.client_secret,
codeVerifier: body.code_verifier,
redirectUri: body.redirect_uri ?? '',
});
break;
case 'client_credentials':
result = await this.oauthService.clientCredentialsGrant({
clientId: body.client_id ?? '',
clientSecret: body.client_secret ?? '',
});
break;
case 'refresh_token':
result = await this.oauthService.refreshTokenGrant({
refreshToken: body.refresh_token ?? '',
clientId: body.client_id ?? '',
clientSecret: body.client_secret,
});
break;
default:
result = {
error: 'unsupported_grant_type',
error_description:
'The provided grant_type is not supported. Supported values: authorization_code, client_credentials, refresh_token',
};
break;
}
res.status('error' in result ? 400 : 200);
return result;
}
}
@@ -0,0 +1,22 @@
import { Field, Int, ObjectType } from '@nestjs/graphql';
@ObjectType('VersionDistributionEntry')
export class VersionDistributionEntryDTO {
@Field(() => String)
version: string;
@Field(() => Int)
count: number;
}
@ObjectType('ApplicationRegistrationStats')
export class ApplicationRegistrationStatsDTO {
@Field(() => Int)
activeInstalls: number;
@Field(() => String, { nullable: true })
mostInstalledVersion: string | null;
@Field(() => [VersionDistributionEntryDTO])
versionDistribution: VersionDistributionEntryDTO[];
}
@@ -0,0 +1,37 @@
import { Field, InputType } from '@nestjs/graphql';
import {
IsBoolean,
IsOptional,
IsString,
IsUUID,
MaxLength,
} from 'class-validator';
@InputType()
export class CreateApplicationRegistrationVariableInput {
@Field()
@IsUUID()
applicationRegistrationId: string;
@Field()
@IsString()
@MaxLength(256)
key: string;
@Field()
@IsString()
@MaxLength(10000)
value: string;
@Field({ nullable: true })
@IsString()
@MaxLength(2000)
@IsOptional()
description?: string;
@Field({ nullable: true })
@IsBoolean()
@IsOptional()
isSecret?: boolean;
}
@@ -0,0 +1,12 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application-registration/application-registration.entity';
@ObjectType('CreateApplicationRegistration')
export class CreateApplicationRegistrationDTO {
@Field(() => ApplicationRegistrationEntity)
applicationRegistration: ApplicationRegistrationEntity;
@Field()
clientSecret: string;
}
@@ -0,0 +1,69 @@
import { Field, InputType } from '@nestjs/graphql';
import {
ArrayMaxSize,
IsArray,
IsOptional,
IsString,
IsUUID,
MaxLength,
} from 'class-validator';
@InputType()
export class CreateApplicationRegistrationInput {
@Field()
@IsString()
@MaxLength(256)
name: string;
@Field({ nullable: true })
@IsString()
@MaxLength(2000)
@IsOptional()
description?: string;
@Field({ nullable: true })
@IsString()
@MaxLength(2048)
@IsOptional()
logoUrl?: string;
@Field({ nullable: true })
@IsString()
@MaxLength(256)
@IsOptional()
author?: string;
@Field({ nullable: true })
@IsUUID()
@IsOptional()
universalIdentifier?: string;
@Field(() => [String], { nullable: true })
@IsArray()
@ArrayMaxSize(20)
@IsString({ each: true })
@MaxLength(2048, { each: true })
@IsOptional()
oAuthRedirectUris?: string[];
@Field(() => [String], { nullable: true })
@IsArray()
@ArrayMaxSize(50)
@IsString({ each: true })
@MaxLength(256, { each: true })
@IsOptional()
oAuthScopes?: string[];
@Field({ nullable: true })
@IsString()
@MaxLength(2048)
@IsOptional()
websiteUrl?: string;
@Field({ nullable: true })
@IsString()
@MaxLength(2048)
@IsOptional()
termsUrl?: string;
}
@@ -0,0 +1,37 @@
import { IsOptional, IsString, MaxLength } from 'class-validator';
export class OAuthTokenInput {
@IsString()
@MaxLength(50)
grant_type: string;
@IsOptional()
@IsString()
@MaxLength(512)
code?: string;
@IsOptional()
@IsString()
@MaxLength(2048)
redirect_uri?: string;
@IsOptional()
@IsString()
@MaxLength(256)
client_id?: string;
@IsOptional()
@IsString()
@MaxLength(512)
client_secret?: string;
@IsOptional()
@IsString()
@MaxLength(512)
code_verifier?: string;
@IsOptional()
@IsString()
@MaxLength(2048)
refresh_token?: string;
}
@@ -0,0 +1,7 @@
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType('RotateClientSecret')
export class RotateClientSecretDTO {
@Field()
clientSecret: string;
}
@@ -0,0 +1,39 @@
import { Field, InputType } from '@nestjs/graphql';
import { Type } from 'class-transformer';
import {
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
MaxLength,
ValidateNested,
} from 'class-validator';
@InputType()
export class UpdateApplicationRegistrationVariablePayload {
@Field({ nullable: true })
@IsString()
@MaxLength(10000)
@IsOptional()
value?: string;
@Field({ nullable: true })
@IsString()
@MaxLength(2000)
@IsOptional()
description?: string;
}
@InputType()
export class UpdateApplicationRegistrationVariableInput {
@IsNotEmpty()
@Field()
@IsUUID()
id: string;
@Type(() => UpdateApplicationRegistrationVariablePayload)
@ValidateNested()
@Field(() => UpdateApplicationRegistrationVariablePayload)
update: UpdateApplicationRegistrationVariablePayload;
}
@@ -0,0 +1,81 @@
import { Field, InputType } from '@nestjs/graphql';
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
IsArray,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
MaxLength,
ValidateNested,
} from 'class-validator';
@InputType()
export class UpdateApplicationRegistrationPayload {
@Field({ nullable: true })
@IsString()
@MaxLength(256)
@IsOptional()
name?: string;
@Field({ nullable: true })
@IsString()
@MaxLength(2000)
@IsOptional()
description?: string;
@Field({ nullable: true })
@IsString()
@MaxLength(2048)
@IsOptional()
logoUrl?: string;
@Field({ nullable: true })
@IsString()
@MaxLength(256)
@IsOptional()
author?: string;
@Field(() => [String], { nullable: true })
@IsArray()
@ArrayMaxSize(20)
@IsString({ each: true })
@MaxLength(2048, { each: true })
@IsOptional()
oAuthRedirectUris?: string[];
@Field(() => [String], { nullable: true })
@IsArray()
@ArrayMaxSize(50)
@IsString({ each: true })
@MaxLength(256, { each: true })
@IsOptional()
oAuthScopes?: string[];
@Field({ nullable: true })
@IsString()
@MaxLength(2048)
@IsOptional()
websiteUrl?: string;
@Field({ nullable: true })
@IsString()
@MaxLength(2048)
@IsOptional()
termsUrl?: string;
}
@InputType()
export class UpdateApplicationRegistrationInput {
@IsNotEmpty()
@Field()
@IsUUID()
id: string;
@Type(() => UpdateApplicationRegistrationPayload)
@ValidateNested()
@Field(() => UpdateApplicationRegistrationPayload)
update: UpdateApplicationRegistrationPayload;
}
@@ -0,0 +1,395 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import crypto from 'crypto';
import ms from 'ms';
import { IsNull, Repository } from 'typeorm';
import { base64UrlEncode } from 'twenty-shared/utils';
import {
AppTokenEntity,
AppTokenType,
} from 'src/engine/core-modules/app-token/app-token.entity';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application-registration/application-registration.entity';
import { ApplicationRegistrationService } from 'src/engine/core-modules/application-registration/application-registration.service';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { OAuthErrorResponse } from 'src/engine/core-modules/application-registration/types/oauth-error-response.type';
import { OAuthTokenResponse } from 'src/engine/core-modules/application-registration/types/oauth-token-response.type';
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
@Injectable()
export class OAuthService {
private readonly logger = new Logger(OAuthService.name);
constructor(
@InjectRepository(AppTokenEntity)
private readonly appTokenRepository: Repository<AppTokenEntity>,
@InjectRepository(ApplicationEntity)
private readonly applicationRepository: Repository<ApplicationEntity>,
@InjectRepository(UserWorkspaceEntity)
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
private readonly applicationTokenService: ApplicationTokenService,
private readonly applicationRegistrationService: ApplicationRegistrationService,
private readonly applicationService: ApplicationService,
private readonly twentyConfigService: TwentyConfigService,
) {}
async exchangeAuthorizationCode(params: {
authorizationCode: string;
clientId: string;
clientSecret?: string;
codeVerifier?: string;
redirectUri: string;
}): Promise<OAuthTokenResponse | OAuthErrorResponse> {
const {
authorizationCode,
clientId,
clientSecret,
codeVerifier,
redirectUri,
} = params;
if (!authorizationCode) {
return this.errorResponse(
'invalid_request',
'Authorization code is required',
);
}
const clientValidation = await this.validateClient(clientId);
if ('error' in clientValidation) {
return clientValidation;
}
const applicationRegistration = clientValidation;
if (clientSecret) {
const secretError = await this.validateClientSecret(
applicationRegistration,
clientSecret,
);
if (secretError) {
return secretError;
}
}
const authCodeToken = await this.appTokenRepository.findOne({
where: {
value: authorizationCode,
type: AppTokenType.AuthorizationCode,
revokedAt: IsNull(),
},
});
if (!authCodeToken) {
return this.errorResponse(
'invalid_grant',
'Authorization code not found',
);
}
if (authCodeToken.expiresAt.getTime() < Date.now()) {
return this.errorResponse('invalid_grant', 'Authorization code expired');
}
// RFC 6749 §4.1.3: redirect_uri must match the one used in the authorization request
const storedRedirectUri = authCodeToken.context?.redirectUri;
if (storedRedirectUri) {
if (!redirectUri) {
return this.errorResponse(
'invalid_request',
'redirect_uri is required',
);
}
if (redirectUri !== storedRedirectUri) {
return this.errorResponse(
'invalid_grant',
'redirect_uri does not match the one used in the authorization request',
);
}
}
if (codeVerifier) {
const pkceError = await this.validatePkce(codeVerifier, authCodeToken);
if (pkceError) {
return pkceError;
}
}
if (!clientSecret && !codeVerifier) {
return this.errorResponse(
'invalid_request',
'Either client_secret or code_verifier (PKCE) is required',
);
}
await this.appTokenRepository.update(authCodeToken.id, {
revokedAt: new Date(),
});
if (!authCodeToken.userId || !authCodeToken.workspaceId) {
return this.errorResponse(
'server_error',
'Authorization code is missing user or workspace context',
);
}
const application = await this.findOrInstallApplication(
applicationRegistration,
authCodeToken.workspaceId,
);
const userWorkspace = await this.userWorkspaceRepository.findOne({
where: {
userId: authCodeToken.userId,
workspaceId: authCodeToken.workspaceId,
},
});
const { applicationAccessToken, applicationRefreshToken } =
await this.applicationTokenService.generateApplicationTokenPair({
workspaceId: authCodeToken.workspaceId,
applicationId: application.id,
userId: authCodeToken.userId,
userWorkspaceId: userWorkspace?.id,
});
return {
access_token: applicationAccessToken.token,
token_type: 'Bearer',
expires_in: this.getAccessTokenExpiresInSeconds(),
refresh_token: applicationRefreshToken.token,
scope: applicationRegistration.oAuthScopes.join(' '),
};
}
async clientCredentialsGrant(params: {
clientId: string;
clientSecret: string;
}): Promise<OAuthTokenResponse | OAuthErrorResponse> {
const { clientId, clientSecret } = params;
const clientValidation = await this.validateClient(clientId);
if ('error' in clientValidation) {
return clientValidation;
}
const applicationRegistration = clientValidation;
const secretError = await this.validateClientSecret(
applicationRegistration,
clientSecret,
);
if (secretError) {
return secretError;
}
const applications = await this.applicationRepository.find({
where: { applicationRegistrationId: applicationRegistration.id },
});
if (applications.length === 0) {
return this.errorResponse(
'server_error',
'No workspace installation found for this client. Install the app in a workspace first.',
);
}
if (applications.length > 1) {
return this.errorResponse(
'invalid_request',
'Multiple workspace installations found. Client credentials grant requires exactly one installation.',
);
}
const application = applications[0];
const applicationAccessToken =
await this.applicationTokenService.generateApplicationAccessToken({
workspaceId: application.workspaceId,
applicationId: application.id,
});
return {
access_token: applicationAccessToken.token,
token_type: 'Bearer',
expires_in: this.getAccessTokenExpiresInSeconds(),
scope: applicationRegistration.oAuthScopes.join(' '),
};
}
async refreshTokenGrant(params: {
refreshToken: string;
clientId: string;
clientSecret?: string;
}): Promise<OAuthTokenResponse | OAuthErrorResponse> {
const { refreshToken, clientId, clientSecret } = params;
const clientValidation = await this.validateClient(clientId);
if ('error' in clientValidation) {
return clientValidation;
}
const applicationRegistration = clientValidation;
if (clientSecret) {
const secretError = await this.validateClientSecret(
applicationRegistration,
clientSecret,
);
if (secretError) {
return secretError;
}
}
try {
const payload =
this.applicationTokenService.validateApplicationRefreshToken(
refreshToken,
);
const { applicationAccessToken, applicationRefreshToken } =
await this.applicationTokenService.renewApplicationTokens(payload);
return {
access_token: applicationAccessToken.token,
token_type: 'Bearer',
expires_in: this.getAccessTokenExpiresInSeconds(),
refresh_token: applicationRefreshToken.token,
scope: applicationRegistration.oAuthScopes.join(' '),
};
} catch (error) {
this.logger.error('Refresh token grant failed', error);
return this.errorResponse(
'invalid_grant',
'Invalid or expired refresh token',
);
}
}
private async validateClient(
clientId: string,
): Promise<ApplicationRegistrationEntity | OAuthErrorResponse> {
const applicationRegistration =
await this.applicationRegistrationService.findOneByClientId(clientId);
if (!applicationRegistration) {
return this.errorResponse('invalid_client', 'Client not found');
}
return applicationRegistration;
}
private async validateClientSecret(
applicationRegistration: ApplicationRegistrationEntity,
clientSecret: string,
): Promise<OAuthErrorResponse | null> {
const isValid =
await this.applicationRegistrationService.verifyClientSecret(
applicationRegistration,
clientSecret,
);
if (!isValid) {
return this.errorResponse('invalid_client', 'Invalid client secret');
}
return null;
}
private async validatePkce(
codeVerifier: string,
authCodeToken: AppTokenEntity,
): Promise<OAuthErrorResponse | null> {
const codeChallenge = base64UrlEncode(
crypto.createHash('sha256').update(codeVerifier).digest(),
);
const challengeToken = await this.appTokenRepository.findOne({
where: {
value: codeChallenge,
type: AppTokenType.CodeChallenge,
revokedAt: IsNull(),
...(authCodeToken.userId ? { userId: authCodeToken.userId } : {}),
},
});
if (!challengeToken) {
return this.errorResponse(
'invalid_grant',
'Code verifier does not match the code challenge',
);
}
if (challengeToken.expiresAt.getTime() < Date.now()) {
return this.errorResponse('invalid_grant', 'Code challenge expired');
}
await this.appTokenRepository.update(challengeToken.id, {
revokedAt: new Date(),
});
return null;
}
private async findOrInstallApplication(
applicationRegistration: ApplicationRegistrationEntity,
workspaceId: string,
): Promise<ApplicationEntity> {
const existingApplication = await this.applicationRepository.findOne({
where: {
applicationRegistrationId: applicationRegistration.id,
workspaceId,
},
});
if (existingApplication) {
return existingApplication;
}
this.logger.log(
`Auto-installing application "${applicationRegistration.name}" in workspace ${workspaceId}`,
);
// TODO: defaulting to version 0.0.0, build better system
return this.applicationService.create({
universalIdentifier: applicationRegistration.universalIdentifier,
name: applicationRegistration.name,
description: applicationRegistration.description,
version: '0.0.0',
sourcePath: 'oauth-install',
applicationRegistrationId: applicationRegistration.id,
workspaceId,
});
}
// OAuth RFC 6749 requires expires_in as seconds
private getAccessTokenExpiresInSeconds(): number {
const duration = this.twentyConfigService.get(
'APPLICATION_ACCESS_TOKEN_EXPIRES_IN',
);
return Math.floor(ms(duration) / 1000);
}
private errorResponse(
error: string,
errorDescription: string,
): OAuthErrorResponse {
return { error, error_description: errorDescription };
}
}
@@ -0,0 +1,4 @@
export type OAuthErrorResponse = {
error: string;
error_description: string;
};
@@ -0,0 +1,7 @@
export type OAuthTokenResponse = {
access_token: string;
token_type: string;
expires_in: number;
refresh_token?: string;
scope?: string;
};
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ApplicationRegistrationModule } from 'src/engine/core-modules/application-registration/application-registration.module';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { ApplicationDevelopmentResolver } from 'src/engine/core-modules/application/resolvers/application-development.resolver';
import { ApplicationResolver } from 'src/engine/core-modules/application/resolvers/application.resolver';
@@ -24,6 +25,7 @@ import { CodeStepBuildModule } from 'src/modules/workflow/workflow-builder/workf
@Module({
imports: [
TypeOrmModule.forFeature([FileEntity]),
ApplicationRegistrationModule,
ApplicationModule,
ApplicationVariableEntityModule,
TokenModule,
@@ -7,6 +7,7 @@ import {
Entity,
Index,
JoinColumn,
ManyToOne,
OneToMany,
OneToOne,
PrimaryGeneratedColumn,
@@ -14,6 +15,7 @@ import {
UpdateDateColumn,
} from 'typeorm';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application-registration/application-registration.entity';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { ApplicationVariableEntity } from 'src/engine/core-modules/applicationVariable/application-variable.entity';
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
@@ -94,6 +96,16 @@ export class ApplicationEntity extends WorkspaceRelatedEntity {
@Column({ nullable: false, type: 'boolean', default: true })
canBeUninstalled: boolean;
@Column({ nullable: true, type: 'uuid' })
applicationRegistrationId: string | null;
@ManyToOne(() => ApplicationRegistrationEntity, {
onDelete: 'SET NULL',
nullable: true,
})
@JoinColumn({ name: 'applicationRegistrationId' })
applicationRegistration: Relation<ApplicationRegistrationEntity> | null;
@OneToMany(() => AgentEntity, (agent) => agent.application, {
onDelete: 'CASCADE',
})
@@ -8,4 +8,5 @@ export const APPLICATION_ENTITY_RELATION_PROPERTIES = [
'applicationVariables',
'packageJsonFile',
'yarnLockFile',
'applicationRegistration',
] as const satisfies (keyof ApplicationEntity)[];
@@ -1,6 +1,6 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
@InputType()
export class CreateApplicationInput {
@@ -28,4 +28,9 @@ export class CreateApplicationInput {
@IsNotEmpty()
@Field()
sourcePath: string;
@IsUUID()
@IsOptional()
@Field({ nullable: true })
applicationRegistrationId?: string;
}
@@ -2,7 +2,7 @@ import { Field, ObjectType } from '@nestjs/graphql';
import GraphQLJSON from 'graphql-type-json';
@ObjectType()
@ObjectType('WorkspaceMigration')
export class WorkspaceMigrationDTO {
@Field(() => String)
applicationUniversalIdentifier: string;
@@ -5,6 +5,8 @@ import { FileFolder } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { PackageJson } from 'type-fest';
import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application-registration/application-registration-variable.service';
import { ApplicationRegistrationService } from 'src/engine/core-modules/application-registration/application-registration.service';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import {
ApplicationException,
@@ -38,6 +40,8 @@ export class ApplicationSyncService {
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly fileStorageService: FileStorageService,
private readonly applicationRegistrationService: ApplicationRegistrationService,
private readonly applicationRegistrationVariableService: ApplicationRegistrationVariableService,
) {}
public async synchronizeFromManifest({
@@ -118,13 +122,41 @@ export class ApplicationSyncService {
},
);
const applicationRegistrationMetadata = {
name,
description: manifest.application.description,
logoUrl: manifest.application.logoUrl,
author: manifest.application.author,
websiteUrl: manifest.application.websiteUrl,
termsUrl: manifest.application.termsUrl,
};
const applicationRegistrationId =
await this.resolveApplicationRegistrationId(
application.applicationRegistrationId,
manifest.application.universalIdentifier,
applicationRegistrationMetadata,
);
await this.applicationRegistrationService.update({
id: applicationRegistrationId,
update: applicationRegistrationMetadata,
});
if (manifest.application.serverVariables) {
await this.applicationRegistrationVariableService.syncVariableSchemas(
applicationRegistrationId,
manifest.application.serverVariables,
);
}
return await this.applicationService.update(application.id, {
name,
description: manifest.application.description,
version: packageJson.version,
packageJsonChecksum: manifest.application.packageJsonChecksum,
yarnLockChecksum: manifest.application.yarnLockChecksum,
//availablePackages: manifest.application.availablePackages, // TODO: compute available package in dev-mode-orchestrator
applicationRegistrationId,
});
}
@@ -204,4 +236,42 @@ export class ApplicationSyncService {
return validateAndBuildResult.workspaceMigration;
}
private async resolveApplicationRegistrationId(
existingId: string | null,
universalIdentifier: string,
metadata: {
name: string;
description?: string;
logoUrl?: string;
author?: string;
websiteUrl?: string;
termsUrl?: string;
},
): Promise<string> {
if (existingId) {
return existingId;
}
const existingRegistration =
await this.applicationRegistrationService.findOneByUniversalIdentifier(
universalIdentifier,
);
if (existingRegistration) {
return existingRegistration.id;
}
const { applicationRegistration: newRegistration } =
await this.applicationRegistrationService.create(
{ ...metadata, universalIdentifier },
null,
);
this.logger.log(
`Created app registration for ${metadata.name} (${universalIdentifier})`,
);
return newRegistration.id;
}
}
@@ -191,9 +191,8 @@ export class MarketplaceService {
const packageJson = JSON.parse(packageJsonContent) as PackageJson;
const { application } = manifest;
const marketplaceData = application.marketplaceData;
if (!marketplaceData?.author || !marketplaceData?.category) {
if (!application.author || !application.category) {
return null;
}
@@ -258,14 +257,14 @@ export class MarketplaceService {
description: application.description ?? '',
icon: application.icon ?? 'IconApps',
version: packageJson.version ?? '0.1.0',
author: marketplaceData.author,
category: marketplaceData.category,
logo: this.resolveAssetUrl(appPath, marketplaceData.logo),
screenshots: this.resolveAssetUrls(appPath, marketplaceData.screenshots),
aboutDescription: marketplaceData.aboutDescription ?? '',
providers: marketplaceData.providers ?? [],
websiteUrl: marketplaceData.websiteUrl,
termsUrl: marketplaceData.termsUrl,
author: application.author,
category: application.category,
logo: this.resolveAssetUrl(appPath, application.logoUrl),
screenshots: this.resolveAssetUrls(appPath, application.screenshots),
aboutDescription: application.aboutDescription ?? '',
providers: application.providers ?? [],
websiteUrl: application.websiteUrl,
termsUrl: application.termsUrl,
objects,
fields,
logicFunctions,
@@ -4,6 +4,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module';
import { ApplicationRegistrationModule } from 'src/engine/core-modules/application-registration/application-registration.module';
import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity';
import { AppTokenService } from 'src/engine/core-modules/app-token/services/app-token.service';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
@@ -117,6 +118,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
AuditModule,
SubdomainManagerModule,
DomainServerConfigModule,
ApplicationRegistrationModule,
ApplicationModule,
WorkspaceCacheModule,
SecureHttpClientModule,
@@ -24,7 +24,6 @@ import { PermissionsService } from 'src/engine/metadata-modules/permissions/perm
import { AuthResolver } from './auth.resolver';
import { AuthService } from './services/auth.service';
// import { OAuthService } from './services/oauth.service';
import { ResetPasswordService } from './services/reset-password.service';
import { EmailVerificationTokenService } from './token/services/email-verification-token.service';
import { LoginTokenService } from './token/services/login-token.service';
@@ -139,10 +138,6 @@ describe('AuthResolver', () => {
}),
},
},
// {
// provide: OAuthService,
// useValue: {},
// },
],
})
.overrideGuard(CaptchaGuard)
@@ -11,17 +11,16 @@ import { Repository } from 'typeorm';
import { ApiKeyTokenInput } from 'src/engine/core-modules/auth/dto/api-key-token.input';
import { AppTokenInput } from 'src/engine/core-modules/auth/dto/app-token.input';
import { AuthorizeAppOutput } from 'src/engine/core-modules/auth/dto/authorize-app.dto';
import { AuthorizeAppDTO } from 'src/engine/core-modules/auth/dto/authorize-app.dto';
import { AuthorizeAppInput } from 'src/engine/core-modules/auth/dto/authorize-app.input';
import { EmailPasswordResetLinkOutput } from 'src/engine/core-modules/auth/dto/email-password-reset-link.dto';
import { EmailPasswordResetLinkDTO } from 'src/engine/core-modules/auth/dto/email-password-reset-link.dto';
import { EmailPasswordResetLinkInput } from 'src/engine/core-modules/auth/dto/email-password-reset-link.input';
import { InvalidatePasswordOutput } from 'src/engine/core-modules/auth/dto/invalidate-password.dto';
import { TransientTokenOutput } from 'src/engine/core-modules/auth/dto/transient-token.dto';
import { InvalidatePasswordDTO } from 'src/engine/core-modules/auth/dto/invalidate-password.dto';
import { TransientTokenDTO } from 'src/engine/core-modules/auth/dto/transient-token.dto';
import { UpdatePasswordViaResetTokenInput } from 'src/engine/core-modules/auth/dto/update-password-via-reset-token.input';
import { ValidatePasswordResetTokenOutput } from 'src/engine/core-modules/auth/dto/validate-password-reset-token.dto';
import { ValidatePasswordResetTokenDTO } from 'src/engine/core-modules/auth/dto/validate-password-reset-token.dto';
import { ValidatePasswordResetTokenInput } from 'src/engine/core-modules/auth/dto/validate-password-reset-token.input';
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
// import { OAuthService } from 'src/engine/core-modules/auth/services/oauth.service';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { ApiKeyService } from 'src/engine/core-modules/api-key/services/api-key.service';
import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity';
@@ -31,12 +30,12 @@ import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { AvailableWorkspacesAndAccessTokensOutput } from 'src/engine/core-modules/auth/dto/available-workspaces-and-access-tokens.output';
import { AvailableWorkspacesAndAccessTokensDTO } from 'src/engine/core-modules/auth/dto/available-workspaces-and-access-tokens.dto';
import { GetAuthTokenFromEmailVerificationTokenInput } from 'src/engine/core-modules/auth/dto/get-auth-token-from-email-verification-token.input';
import { GetAuthorizationUrlForSSOInput } from 'src/engine/core-modules/auth/dto/get-authorization-url-for-sso.input';
import { GetAuthorizationUrlForSSOOutput } from 'src/engine/core-modules/auth/dto/get-authorization-url-for-sso.output';
import { SignUpOutput } from 'src/engine/core-modules/auth/dto/sign-up.output';
import { VerifyEmailAndGetLoginTokenOutput } from 'src/engine/core-modules/auth/dto/verify-email-and-get-login-token.output';
import { GetAuthorizationUrlForSSODTO } from 'src/engine/core-modules/auth/dto/get-authorization-url-for-sso.dto';
import { SignUpDTO } from 'src/engine/core-modules/auth/dto/sign-up.dto';
import { VerifyEmailAndGetLoginTokenDTO } from 'src/engine/core-modules/auth/dto/verify-email-and-get-login-token.dto';
import { ResetPasswordService } from 'src/engine/core-modules/auth/services/reset-password.service';
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
import { EmailVerificationTokenService } from 'src/engine/core-modules/auth/token/services/email-verification-token.service';
@@ -82,12 +81,12 @@ import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-module
import { ApiKeyToken } from './dto/api-key-token.dto';
import { AuthTokens } from './dto/auth-tokens.dto';
import { GetAuthTokensFromLoginTokenInput } from './dto/get-auth-tokens-from-login-token.input';
import { LoginTokenOutput } from './dto/login-token.dto';
import { LoginTokenDTO } from './dto/login-token.dto';
import { SignUpInput } from './dto/sign-up.input';
import { UserCredentialsInput } from './dto/user-credentials.input';
import { CheckUserExistOutput } from './dto/user-exists.dto';
import { CheckUserExistDTO } from './dto/user-exists.dto';
import { EmailAndCaptchaInput } from './dto/user-exists.input';
import { WorkspaceInviteHashValidOutput } from './dto/workspace-invite-hash-valid.dto';
import { WorkspaceInviteHashValidDTO } from './dto/workspace-invite-hash-valid.dto';
import { WorkspaceInviteHashValidInput } from './dto/workspace-invite-hash.input';
import { AuthService } from './services/auth.service';
@@ -128,16 +127,16 @@ export class AuthResolver {
) {}
@UseGuards(CaptchaGuard, PublicEndpointGuard, NoPermissionGuard)
@Query(() => CheckUserExistOutput)
@Query(() => CheckUserExistDTO)
async checkUserExists(
@Args() checkUserExistsInput: EmailAndCaptchaInput,
): Promise<CheckUserExistOutput> {
): Promise<CheckUserExistDTO> {
return await this.authService.checkUserExists(
checkUserExistsInput.email.toLowerCase(),
);
}
@Mutation(() => GetAuthorizationUrlForSSOOutput)
@Mutation(() => GetAuthorizationUrlForSSODTO)
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
async getAuthorizationUrlForSSO(
@Args('input') params: GetAuthorizationUrlForSSOInput,
@@ -148,11 +147,11 @@ export class AuthResolver {
);
}
@Query(() => WorkspaceInviteHashValidOutput)
@Query(() => WorkspaceInviteHashValidDTO)
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
async checkWorkspaceInviteHashIsValid(
@Args() workspaceInviteHashValidInput: WorkspaceInviteHashValidInput,
): Promise<WorkspaceInviteHashValidOutput> {
): Promise<WorkspaceInviteHashValidDTO> {
return await this.authService.checkWorkspaceInviteHashIsValid(
workspaceInviteHashValidInput.inviteHash,
);
@@ -168,13 +167,13 @@ export class AuthResolver {
);
}
@Mutation(() => LoginTokenOutput)
@Mutation(() => LoginTokenDTO)
@UseGuards(CaptchaGuard, PublicEndpointGuard, NoPermissionGuard)
async getLoginTokenFromCredentials(
@Args()
getLoginTokenFromCredentialsInput: UserCredentialsInput,
@Args('origin') origin: string,
): Promise<LoginTokenOutput> {
): Promise<LoginTokenDTO> {
const workspace =
await this.workspaceDomainsService.getWorkspaceByOriginOrDefaultWorkspace(
origin,
@@ -203,12 +202,12 @@ export class AuthResolver {
return { loginToken };
}
@Mutation(() => AvailableWorkspacesAndAccessTokensOutput)
@Mutation(() => AvailableWorkspacesAndAccessTokensDTO)
@UseGuards(CaptchaGuard, PublicEndpointGuard, NoPermissionGuard)
async signIn(
@Args()
userCredentials: UserCredentialsInput,
): Promise<AvailableWorkspacesAndAccessTokensOutput> {
): Promise<AvailableWorkspacesAndAccessTokensDTO> {
const user =
await this.authService.validateLoginWithPassword(userCredentials);
@@ -241,7 +240,7 @@ export class AuthResolver {
};
}
@Mutation(() => VerifyEmailAndGetLoginTokenOutput)
@Mutation(() => VerifyEmailAndGetLoginTokenDTO)
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
async verifyEmailAndGetLoginToken(
@Args()
@@ -254,7 +253,10 @@ export class AuthResolver {
getAuthTokenFromEmailVerificationTokenInput,
);
if (appToken.context && appToken.context.email !== appToken.user.email) {
if (
appToken.context?.email &&
appToken.context.email !== appToken.user.email
) {
await this.userService.updateEmailFromVerificationToken(
appToken.user.id,
appToken.context.email,
@@ -283,7 +285,7 @@ export class AuthResolver {
return { loginToken, workspaceUrls };
}
@Mutation(() => AvailableWorkspacesAndAccessTokensOutput)
@Mutation(() => AvailableWorkspacesAndAccessTokensDTO)
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
async verifyEmailAndGetWorkspaceAgnosticToken(
@Args()
@@ -295,7 +297,10 @@ export class AuthResolver {
getAuthTokenFromEmailVerificationTokenInput,
);
if (appToken.context && appToken.context.email !== appToken.user.email) {
if (
appToken.context?.email &&
appToken.context.email !== appToken.user.email
) {
await this.userService.updateEmailFromVerificationToken(
appToken.user.id,
appToken.context.email,
@@ -373,11 +378,11 @@ export class AuthResolver {
return await this.authService.verify(email, workspace.id, authProvider);
}
@Mutation(() => AvailableWorkspacesAndAccessTokensOutput)
@Mutation(() => AvailableWorkspacesAndAccessTokensDTO)
@UseGuards(CaptchaGuard, PublicEndpointGuard, NoPermissionGuard)
async signUp(
@Args() signUpInput: UserCredentialsInput,
): Promise<AvailableWorkspacesAndAccessTokensOutput> {
): Promise<AvailableWorkspacesAndAccessTokensDTO> {
const user = await this.signInUpService.signUpWithoutWorkspace(
{
email: signUpInput.email,
@@ -426,12 +431,12 @@ export class AuthResolver {
};
}
@Mutation(() => SignUpOutput)
@Mutation(() => SignUpDTO)
@UseGuards(CaptchaGuard, PublicEndpointGuard, NoPermissionGuard)
async signUpInWorkspace(
@Args() signUpInput: SignUpInput,
@AuthProvider() authProvider: AuthProviderEnum,
): Promise<SignUpOutput> {
): Promise<SignUpDTO> {
const currentWorkspace = await this.authService.findWorkspaceForSignInUp({
workspaceInviteHash: signUpInput.workspaceInviteHash,
authProvider: AuthProviderEnum.Password,
@@ -500,12 +505,12 @@ export class AuthResolver {
};
}
@Mutation(() => SignUpOutput)
@Mutation(() => SignUpDTO)
@UseGuards(UserAuthGuard, NoPermissionGuard)
async signUpInNewWorkspace(
@AuthUser() currentUser: UserEntity,
@AuthProvider() authProvider: AuthProviderEnum,
): Promise<SignUpOutput> {
): Promise<SignUpDTO> {
const { user, workspace } = await this.signInUpService.signUpOnNewWorkspace(
{ type: 'existingUser', existingUser: currentUser },
);
@@ -525,12 +530,12 @@ export class AuthResolver {
};
}
@Mutation(() => TransientTokenOutput)
@Mutation(() => TransientTokenDTO)
@UseGuards(UserAuthGuard, NoPermissionGuard)
async generateTransientToken(
@AuthUser() user: UserEntity,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<TransientTokenOutput | void> {
): Promise<TransientTokenDTO | void> {
const workspaceMember = await this.userService.loadWorkspaceMember(
user,
workspace,
@@ -771,13 +776,13 @@ export class AuthResolver {
};
}
@Mutation(() => AuthorizeAppOutput)
@Mutation(() => AuthorizeAppDTO)
@UseGuards(UserAuthGuard, NoPermissionGuard)
async authorizeApp(
@Args() authorizeAppInput: AuthorizeAppInput,
@AuthUser() user: UserEntity,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<AuthorizeAppOutput> {
): Promise<AuthorizeAppDTO> {
return await this.authService.generateAuthorizationCode(
authorizeAppInput,
user,
@@ -811,12 +816,12 @@ export class AuthResolver {
);
}
@Mutation(() => EmailPasswordResetLinkOutput)
@Mutation(() => EmailPasswordResetLinkDTO)
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
async emailPasswordResetLink(
@Args() emailPasswordResetInput: EmailPasswordResetLinkInput,
@Context() context: I18nContext,
): Promise<EmailPasswordResetLinkOutput> {
): Promise<EmailPasswordResetLinkDTO> {
const resetToken =
await this.resetPasswordService.generatePasswordResetToken(
emailPasswordResetInput.email,
@@ -830,12 +835,12 @@ export class AuthResolver {
);
}
@Mutation(() => InvalidatePasswordOutput)
@Mutation(() => InvalidatePasswordDTO)
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
async updatePasswordViaResetToken(
@Args()
{ passwordResetToken, newPassword }: UpdatePasswordViaResetTokenInput,
): Promise<InvalidatePasswordOutput> {
): Promise<InvalidatePasswordDTO> {
const { id } =
await this.resetPasswordService.validatePasswordResetToken(
passwordResetToken,
@@ -846,11 +851,11 @@ export class AuthResolver {
return await this.resetPasswordService.invalidatePasswordResetToken(id);
}
@Query(() => ValidatePasswordResetTokenOutput)
@Query(() => ValidatePasswordResetTokenDTO)
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
async validatePasswordResetToken(
@Args() args: ValidatePasswordResetTokenInput,
): Promise<ValidatePasswordResetTokenOutput> {
): Promise<ValidatePasswordResetTokenDTO> {
return this.resetPasswordService.validatePasswordResetToken(
args.passwordResetToken,
);
@@ -1,7 +1,7 @@
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType()
export class AuthorizeAppOutput {
@ObjectType('AuthorizeApp')
export class AuthorizeAppDTO {
@Field(() => String)
redirectUrl: string;
}
@@ -1,11 +1,11 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { AvailableWorkspaces } from 'src/engine/core-modules/auth/dto/available-workspaces.output';
import { AvailableWorkspaces } from 'src/engine/core-modules/auth/dto/available-workspaces.dto';
import { AuthTokenPair } from './auth-token-pair.dto';
@ObjectType()
export class AvailableWorkspacesAndAccessTokensOutput {
@ObjectType('AvailableWorkspacesAndAccessTokens')
export class AvailableWorkspacesAndAccessTokensDTO {
@Field(() => AuthTokenPair)
tokens: AuthTokenPair;
@@ -1,7 +1,7 @@
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType()
export class EmailPasswordResetLinkOutput {
@ObjectType('EmailPasswordResetLink')
export class EmailPasswordResetLinkDTO {
@Field(() => Boolean, {
description: 'Boolean that confirms query was dispatched',
})
@@ -2,8 +2,8 @@ import { Field, ObjectType } from '@nestjs/graphql';
import { AuthToken } from 'src/engine/core-modules/auth/dto/auth-token.dto';
@ObjectType()
export class ExchangeAuthCodeOutput {
@ObjectType('ExchangeAuthCode')
export class ExchangeAuthCodeDTO {
@Field(() => AuthToken)
accessOrWorkspaceAgnosticToken: AuthToken;
@@ -1,15 +0,0 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { AuthToken } from 'src/engine/core-modules/auth/dto/auth-token.dto';
@ObjectType()
export class ExchangeAuthCode {
@Field(() => AuthToken)
accessOrWorkspaceAgnosticToken: AuthToken;
@Field(() => AuthToken)
refreshToken: AuthToken;
@Field(() => AuthToken)
loginToken: AuthToken;
}
@@ -5,8 +5,8 @@ import { Field, ObjectType } from '@nestjs/graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { type SSOConfiguration } from 'src/engine/core-modules/sso/types/SSOConfigurations.type';
@ObjectType()
export class GetAuthorizationUrlForSSOOutput {
@ObjectType('GetAuthorizationUrlForSSO')
export class GetAuthorizationUrlForSSODTO {
@Field(() => String)
authorizationURL: string;
@@ -1,7 +1,7 @@
import { ObjectType, Field } from '@nestjs/graphql';
@ObjectType()
export class InvalidatePasswordOutput {
@ObjectType('InvalidatePassword')
export class InvalidatePasswordDTO {
@Field(() => Boolean, {
description: 'Boolean that confirms query was dispatched',
})
@@ -2,8 +2,8 @@ import { Field, ObjectType } from '@nestjs/graphql';
import { AuthToken } from 'src/engine/core-modules/auth/dto/auth-token.dto';
@ObjectType()
export class LoginTokenOutput {
@ObjectType('LoginToken')
export class LoginTokenDTO {
@Field(() => AuthToken)
loginToken: AuthToken;
}

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