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:
@@ -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
@@ -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 />}
|
||||
|
||||
+1
-1
@@ -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(
|
||||
|
||||
+2
-2
@@ -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,
|
||||
});
|
||||
|
||||
+10
-2
@@ -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>
|
||||
|
||||
+19
@@ -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
|
||||
}
|
||||
`;
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const DELETE_APPLICATION_REGISTRATION = gql`
|
||||
mutation DeleteApplicationRegistration($id: String!) {
|
||||
deleteApplicationRegistration(id: $id)
|
||||
}
|
||||
`;
|
||||
+9
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
+14
@@ -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}
|
||||
`;
|
||||
+18
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
+13
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
+14
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
+20
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
+12
@@ -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}
|
||||
`;
|
||||
+12
@@ -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:
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+626
@@ -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 />;
|
||||
}
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { createAtomFamilyState } from '@/ui/utilities/state/jotai/utils/createAtomFamilyState';
|
||||
|
||||
export const applicationRegistrationClientSecretFamilyState =
|
||||
createAtomFamilyState<string | null, string>({
|
||||
key: 'applicationRegistrationClientSecretState',
|
||||
defaultValue: null,
|
||||
});
|
||||
-68
@@ -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>
|
||||
);
|
||||
};
|
||||
+118
@@ -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,
|
||||
|
||||
+11
-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);
|
||||
|
||||
+12
@@ -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
-1
@@ -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);
|
||||
|
||||
+16
-2
@@ -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) {
|
||||
|
||||
+120
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
+2
@@ -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) {
|
||||
|
||||
+143
@@ -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
-2
@@ -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[];
|
||||
}
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+2
-2
@@ -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;
|
||||
}
|
||||
|
||||
+25
@@ -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');
|
||||
});
|
||||
});
|
||||
+74
@@ -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;
|
||||
}
|
||||
+165
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+113
@@ -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;
|
||||
}
|
||||
+45
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
+46
@@ -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 {}
|
||||
+181
@@ -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);
|
||||
}
|
||||
}
|
||||
+264
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
@@ -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",
|
||||
};
|
||||
+32
@@ -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'],
|
||||
};
|
||||
}
|
||||
}
|
||||
+75
@@ -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;
|
||||
}
|
||||
}
|
||||
+22
@@ -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[];
|
||||
}
|
||||
+37
@@ -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;
|
||||
}
|
||||
+12
@@ -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;
|
||||
}
|
||||
+69
@@ -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;
|
||||
}
|
||||
+37
@@ -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;
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType('RotateClientSecret')
|
||||
export class RotateClientSecretDTO {
|
||||
@Field()
|
||||
clientSecret: string;
|
||||
}
|
||||
+39
@@ -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;
|
||||
}
|
||||
+81
@@ -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;
|
||||
}
|
||||
+395
@@ -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 };
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export type OAuthErrorResponse = {
|
||||
error: string;
|
||||
error_description: string;
|
||||
};
|
||||
+7
@@ -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',
|
||||
})
|
||||
|
||||
+1
@@ -8,4 +8,5 @@ export const APPLICATION_ENTITY_RELATION_PROPERTIES = [
|
||||
'applicationVariables',
|
||||
'packageJsonFile',
|
||||
'yarnLockFile',
|
||||
'applicationRegistration',
|
||||
] as const satisfies (keyof ApplicationEntity)[];
|
||||
|
||||
+6
-1
@@ -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;
|
||||
}
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+71
-1
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+9
-10
@@ -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;
|
||||
}
|
||||
|
||||
+3
-3
@@ -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;
|
||||
|
||||
+2
-2
@@ -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;
|
||||
}
|
||||
+2
-2
@@ -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
Reference in New Issue
Block a user