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:
@@ -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;
|
||||
}
|
||||
|
||||
+2
-2
@@ -4,8 +4,8 @@ import { WorkspaceUrlsAndIdDTO } from 'src/engine/core-modules/workspace/dtos/wo
|
||||
|
||||
import { AuthToken } from './auth-token.dto';
|
||||
|
||||
@ObjectType('SignUpOutput')
|
||||
export class SignUpOutput {
|
||||
@ObjectType('SignUp')
|
||||
export class SignUpDTO {
|
||||
@Field(() => AuthToken)
|
||||
loginToken: AuthToken;
|
||||
|
||||
@@ -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 TransientTokenOutput {
|
||||
@ObjectType('TransientToken')
|
||||
export class TransientTokenDTO {
|
||||
@Field(() => AuthToken)
|
||||
transientToken: AuthToken;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ObjectType, Field } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType()
|
||||
export class UpdatePasswordOutput {
|
||||
@ObjectType('UpdatePassword')
|
||||
export class UpdatePasswordDTO {
|
||||
@Field(() => Boolean, {
|
||||
description: 'Boolean that confirms query was dispatched',
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType()
|
||||
export class CheckUserExistOutput {
|
||||
@ObjectType('CheckUserExist')
|
||||
export class CheckUserExistDTO {
|
||||
@Field(() => Boolean)
|
||||
exists: boolean;
|
||||
|
||||
|
||||
+2
-2
@@ -2,8 +2,8 @@ import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
|
||||
@ObjectType()
|
||||
export class ValidatePasswordResetTokenOutput {
|
||||
@ObjectType('ValidatePasswordResetToken')
|
||||
export class ValidatePasswordResetTokenDTO {
|
||||
@Field(() => UUIDScalarType)
|
||||
id: string;
|
||||
|
||||
|
||||
+2
-2
@@ -4,8 +4,8 @@ import { WorkspaceUrlsDTO } from 'src/engine/core-modules/workspace/dtos/workspa
|
||||
|
||||
import { AuthToken } from './auth-token.dto';
|
||||
|
||||
@ObjectType('VerifyEmailAndGetLoginTokenOutput')
|
||||
export class VerifyEmailAndGetLoginTokenOutput {
|
||||
@ObjectType('VerifyEmailAndGetLoginToken')
|
||||
export class VerifyEmailAndGetLoginTokenDTO {
|
||||
@Field(() => AuthToken)
|
||||
loginToken: AuthToken;
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType()
|
||||
export class WorkspaceInviteHashValidOutput {
|
||||
@ObjectType('WorkspaceInviteHashValid')
|
||||
export class WorkspaceInviteHashValidDTO {
|
||||
@Field(() => Boolean)
|
||||
isValid: boolean;
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import { UserEntity } from 'src/engine/core-modules/user/user.entity';
|
||||
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
|
||||
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { ApplicationRegistrationService } from 'src/engine/core-modules/application-registration/application-registration.service';
|
||||
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
|
||||
|
||||
import { AuthService } from './auth.service';
|
||||
@@ -169,6 +170,10 @@ describe('AuthService', () => {
|
||||
.mockResolvedValue(false),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ApplicationRegistrationService,
|
||||
useValue: {},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
|
||||
@@ -13,8 +13,7 @@ import { AppPath } from 'twenty-shared/types';
|
||||
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
|
||||
import { IsNull, Repository } from 'typeorm';
|
||||
|
||||
import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface';
|
||||
|
||||
import { ApplicationRegistrationService } from 'src/engine/core-modules/application-registration/application-registration.service';
|
||||
import {
|
||||
AppTokenEntity,
|
||||
AppTokenType,
|
||||
@@ -30,12 +29,13 @@ import {
|
||||
hashPassword,
|
||||
} from 'src/engine/core-modules/auth/auth.util';
|
||||
import { type AuthTokens } from 'src/engine/core-modules/auth/dto/auth-tokens.dto';
|
||||
import { type AuthorizeAppOutput } from 'src/engine/core-modules/auth/dto/authorize-app.dto';
|
||||
import { validateRedirectUri } from 'src/engine/core-modules/auth/utils/validate-redirect-uri.util';
|
||||
import { type AuthorizeAppDTO } from 'src/engine/core-modules/auth/dto/authorize-app.dto';
|
||||
import { type AuthorizeAppInput } from 'src/engine/core-modules/auth/dto/authorize-app.input';
|
||||
import { type UpdatePasswordOutput } from 'src/engine/core-modules/auth/dto/update-password.dto';
|
||||
import { type UpdatePasswordDTO } from 'src/engine/core-modules/auth/dto/update-password.dto';
|
||||
import { type UserCredentialsInput } from 'src/engine/core-modules/auth/dto/user-credentials.input';
|
||||
import { type CheckUserExistOutput } from 'src/engine/core-modules/auth/dto/user-exists.dto';
|
||||
import { type WorkspaceInviteHashValidOutput } from 'src/engine/core-modules/auth/dto/workspace-invite-hash-valid.dto';
|
||||
import { type CheckUserExistDTO } from 'src/engine/core-modules/auth/dto/user-exists.dto';
|
||||
import { type WorkspaceInviteHashValidDTO } from 'src/engine/core-modules/auth/dto/workspace-invite-hash-valid.dto';
|
||||
import { AuthSsoService } from 'src/engine/core-modules/auth/services/auth-sso.service';
|
||||
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
|
||||
import { type GoogleRequest } from 'src/engine/core-modules/auth/strategies/google.auth.strategy';
|
||||
@@ -94,6 +94,7 @@ export class AuthService {
|
||||
private readonly appTokenRepository: Repository<AppTokenEntity>,
|
||||
private readonly i18nService: I18nService,
|
||||
private readonly auditService: AuditService,
|
||||
private readonly applicationRegistrationService: ApplicationRegistrationService,
|
||||
) {}
|
||||
|
||||
private async checkAccessAndUseInvitationOrThrow(
|
||||
@@ -467,7 +468,7 @@ export class AuthService {
|
||||
).flat(2).length;
|
||||
}
|
||||
|
||||
async checkUserExists(email: string): Promise<CheckUserExistOutput> {
|
||||
async checkUserExists(email: string): Promise<CheckUserExistDTO> {
|
||||
const user = await this.userService.findUserByEmail(email);
|
||||
|
||||
const isUserExist = isDefined(user);
|
||||
@@ -482,7 +483,7 @@ export class AuthService {
|
||||
|
||||
async checkWorkspaceInviteHashIsValid(
|
||||
inviteHash: string,
|
||||
): Promise<WorkspaceInviteHashValidOutput> {
|
||||
): Promise<WorkspaceInviteHashValidDTO> {
|
||||
const workspace = await this.workspaceRepository.findOneBy({
|
||||
inviteHash,
|
||||
});
|
||||
@@ -494,51 +495,54 @@ export class AuthService {
|
||||
authorizeAppInput: AuthorizeAppInput,
|
||||
user: UserEntity,
|
||||
workspace: WorkspaceEntity,
|
||||
): Promise<AuthorizeAppOutput> {
|
||||
// TODO: replace with db call to - third party app table
|
||||
const apps = [
|
||||
{
|
||||
id: 'chrome',
|
||||
name: 'Chrome Extension',
|
||||
redirectUrl:
|
||||
this.twentyConfigService.get('NODE_ENV') ===
|
||||
NodeEnvironment.DEVELOPMENT
|
||||
? authorizeAppInput.redirectUrl
|
||||
: `https://${this.twentyConfigService.get(
|
||||
'CHROME_EXTENSION_ID',
|
||||
)}.chromiumapp.org/`,
|
||||
},
|
||||
];
|
||||
|
||||
): Promise<AuthorizeAppDTO> {
|
||||
const { clientId, codeChallenge } = authorizeAppInput;
|
||||
|
||||
const client = apps.find((app) => app.id === clientId);
|
||||
const applicationRegistration =
|
||||
await this.applicationRegistrationService.findOneByClientId(clientId);
|
||||
|
||||
if (!client) {
|
||||
if (!applicationRegistration) {
|
||||
throw new AuthException(
|
||||
`Client not found for '${clientId}'`,
|
||||
AuthExceptionCode.CLIENT_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
if (!client.redirectUrl || !authorizeAppInput.redirectUrl) {
|
||||
if (!authorizeAppInput.redirectUrl) {
|
||||
throw new AuthException(
|
||||
`redirectUrl not found for '${clientId}'`,
|
||||
`redirectUrl not provided for '${clientId}'`,
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
);
|
||||
}
|
||||
|
||||
if (client.redirectUrl !== authorizeAppInput.redirectUrl) {
|
||||
if (
|
||||
!applicationRegistration.oAuthRedirectUris.includes(
|
||||
authorizeAppInput.redirectUrl,
|
||||
)
|
||||
) {
|
||||
throw new AuthException(
|
||||
`redirectUrl mismatch for '${clientId}'`,
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
);
|
||||
}
|
||||
|
||||
const redirectUriValidation = validateRedirectUri(
|
||||
authorizeAppInput.redirectUrl,
|
||||
);
|
||||
|
||||
if (!redirectUriValidation.valid) {
|
||||
throw new AuthException(
|
||||
redirectUriValidation.reason,
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
);
|
||||
}
|
||||
|
||||
const authorizationCode = crypto.randomBytes(42).toString('hex');
|
||||
|
||||
const expiresAt = addMilliseconds(new Date().getTime(), ms('5m'));
|
||||
|
||||
const authCodeContext = { redirectUri: authorizeAppInput.redirectUrl };
|
||||
|
||||
if (codeChallenge) {
|
||||
const tokens = this.appTokenRepository.create([
|
||||
{
|
||||
@@ -554,6 +558,7 @@ export class AuthService {
|
||||
userId: user.id,
|
||||
workspaceId: workspace.id,
|
||||
expiresAt,
|
||||
context: authCodeContext,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -565,22 +570,24 @@ export class AuthService {
|
||||
userId: user.id,
|
||||
workspaceId: workspace.id,
|
||||
expiresAt,
|
||||
context: authCodeContext,
|
||||
});
|
||||
|
||||
await this.appTokenRepository.save(token);
|
||||
}
|
||||
|
||||
const redirectUrl = `${
|
||||
client.redirectUrl ? client.redirectUrl : authorizeAppInput.redirectUrl
|
||||
}?authorizationCode=${authorizationCode}`;
|
||||
redirectUriValidation.parsed.searchParams.set(
|
||||
'authorizationCode',
|
||||
authorizationCode,
|
||||
);
|
||||
|
||||
return { redirectUrl };
|
||||
return { redirectUrl: redirectUriValidation.parsed.toString() };
|
||||
}
|
||||
|
||||
async updatePassword(
|
||||
userId: string,
|
||||
newPassword: string,
|
||||
): Promise<UpdatePasswordOutput> {
|
||||
): Promise<UpdatePasswordDTO> {
|
||||
if (!userId) {
|
||||
throw new AuthException(
|
||||
'User ID is required',
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
// import { Injectable } from '@nestjs/common';
|
||||
// import { InjectRepository } from '@nestjs/typeorm';
|
||||
//
|
||||
// import crypto from 'crypto';
|
||||
//
|
||||
// import { Repository } from 'typeorm';
|
||||
//
|
||||
// import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
// import {
|
||||
// AuthException,
|
||||
// AuthExceptionCode,
|
||||
// } from 'src/engine/core-modules/auth/auth.exception';
|
||||
// import { ExchangeAuthCode } from 'src/engine/core-modules/auth/dto/exchange-auth-code.entity';
|
||||
// import { ExchangeAuthCodeInput } from 'src/engine/core-modules/auth/dto/exchange-auth-code.input';
|
||||
// import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
|
||||
// import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
|
||||
// import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service';
|
||||
// import { UserEntity } from 'src/engine/core-modules/user/user.entity';
|
||||
// import { userValidator } from 'src/engine/core-modules/user/user.validate';
|
||||
//
|
||||
// @Injectable()
|
||||
// export class OAuthService {
|
||||
// constructor(
|
||||
// @InjectRepository(UserEntity)
|
||||
// private readonly userRepository: Repository<UserEntity>,
|
||||
// @InjectRepository(AppTokenEntity)
|
||||
// private readonly appTokenRepository: Repository<AppTokenEntity>,
|
||||
// private readonly accessTokenService: AccessTokenService,
|
||||
// private readonly refreshTokenService: RefreshTokenService,
|
||||
// private readonly loginTokenService: LoginTokenService,
|
||||
// ) {}
|
||||
//
|
||||
// async verifyAuthorizationCode(
|
||||
// exchangeAuthCodeInput: ExchangeAuthCodeInput,
|
||||
// ): Promise<ExchangeAuthCode> {
|
||||
// const { authorizationCode, codeVerifier } = exchangeAuthCodeInput;
|
||||
//
|
||||
// if (!authorizationCode) {
|
||||
// throw new AuthException(
|
||||
// 'Authorization code not found',
|
||||
// AuthExceptionCode.INVALID_INPUT,
|
||||
// );
|
||||
// }
|
||||
//
|
||||
// let userId = '';
|
||||
//
|
||||
// if (codeVerifier) {
|
||||
// const authorizationCodeAppToken = await this.appTokenRepository.findOne({
|
||||
// where: {
|
||||
// value: authorizationCode,
|
||||
// },
|
||||
// });
|
||||
//
|
||||
// if (!authorizationCodeAppToken) {
|
||||
// throw new AuthException(
|
||||
// 'Authorization code does not exist',
|
||||
// AuthExceptionCode.INVALID_INPUT,
|
||||
// );
|
||||
// }
|
||||
//
|
||||
// if (!(authorizationCodeAppToken.expiresAt.getTime() >= Date.now())) {
|
||||
// throw new AuthException(
|
||||
// 'Authorization code expired.',
|
||||
// AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
// );
|
||||
// }
|
||||
//
|
||||
// const codeChallenge = crypto
|
||||
// .createHash('sha256')
|
||||
// .update(codeVerifier)
|
||||
// .digest()
|
||||
// .toString('base64')
|
||||
// .replace(/\+/g, '-')
|
||||
// .replace(/\//g, '_')
|
||||
// .replace(/=/g, '');
|
||||
//
|
||||
// const codeChallengeAppToken = await this.appTokenRepository.findOne({
|
||||
// where: {
|
||||
// value: codeChallenge,
|
||||
// },
|
||||
// });
|
||||
//
|
||||
// if (!codeChallengeAppToken || !codeChallengeAppToken.userId) {
|
||||
// throw new AuthException(
|
||||
// 'code verifier doesnt match the challenge',
|
||||
// AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
// );
|
||||
// }
|
||||
//
|
||||
// if (!(codeChallengeAppToken.expiresAt.getTime() >= Date.now())) {
|
||||
// throw new AuthException(
|
||||
// 'code challenge expired.',
|
||||
// AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
// );
|
||||
// }
|
||||
//
|
||||
// if (codeChallengeAppToken.userId !== authorizationCodeAppToken.userId) {
|
||||
// throw new AuthException(
|
||||
// 'authorization code / code verifier was not created by same client',
|
||||
// AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
// );
|
||||
// }
|
||||
//
|
||||
// if (codeChallengeAppToken.revokedAt) {
|
||||
// throw new AuthException(
|
||||
// 'Token has been revoked.',
|
||||
// AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
// );
|
||||
// }
|
||||
//
|
||||
// await this.appTokenRepository.save({
|
||||
// id: codeChallengeAppToken.id,
|
||||
// revokedAt: new Date(),
|
||||
// });
|
||||
//
|
||||
// userId = codeChallengeAppToken.userId;
|
||||
// }
|
||||
//
|
||||
// const user = await this.userRepository.findOne({
|
||||
// where: { id: userId },
|
||||
// relations: ['defaultWorkspace'],
|
||||
// });
|
||||
//
|
||||
// userValidator.assertIsDefinedOrThrow(
|
||||
// user,
|
||||
// new AuthException(
|
||||
// 'User who generated the token does not exist',
|
||||
// AuthExceptionCode.INVALID_INPUT,
|
||||
// ),
|
||||
// );
|
||||
//
|
||||
// if (!user.defaultWorkspace) {
|
||||
// throw new AuthException(
|
||||
// 'User does not have a default workspace',
|
||||
// AuthExceptionCode.INVALID_DATA,
|
||||
// );
|
||||
// }
|
||||
//
|
||||
// const accessToken = await this.accessTokenService.generateAccessToken(
|
||||
// user.id,
|
||||
// user.defaultWorkspaceId,
|
||||
// );
|
||||
// const refreshToken = await this.refreshTokenService.generateRefreshToken(
|
||||
// user.id,
|
||||
// user.defaultWorkspaceId,
|
||||
// );
|
||||
// const loginToken = await this.loginTokenService.generateLoginToken(
|
||||
// user.email,
|
||||
// );
|
||||
//
|
||||
// return {
|
||||
// accessToken,
|
||||
// refreshToken,
|
||||
// loginToken,
|
||||
// };
|
||||
// }
|
||||
// }
|
||||
+6
-6
@@ -25,10 +25,10 @@ import {
|
||||
AuthException,
|
||||
AuthExceptionCode,
|
||||
} from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { type EmailPasswordResetLinkOutput } from 'src/engine/core-modules/auth/dto/email-password-reset-link.dto';
|
||||
import { type InvalidatePasswordOutput } from 'src/engine/core-modules/auth/dto/invalidate-password.dto';
|
||||
import { type EmailPasswordResetLinkDTO } from 'src/engine/core-modules/auth/dto/email-password-reset-link.dto';
|
||||
import { type InvalidatePasswordDTO } from 'src/engine/core-modules/auth/dto/invalidate-password.dto';
|
||||
import { type PasswordResetToken } from 'src/engine/core-modules/auth/dto/password-reset-token.dto';
|
||||
import { type ValidatePasswordResetTokenOutput } from 'src/engine/core-modules/auth/dto/validate-password-reset-token.dto';
|
||||
import { type ValidatePasswordResetTokenDTO } from 'src/engine/core-modules/auth/dto/validate-password-reset-token.dto';
|
||||
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
|
||||
import { EmailService } from 'src/engine/core-modules/email/email.service';
|
||||
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
|
||||
@@ -119,7 +119,7 @@ export class ResetPasswordService {
|
||||
resetToken: PasswordResetToken,
|
||||
email: string,
|
||||
locale: keyof typeof APP_LOCALES,
|
||||
): Promise<EmailPasswordResetLinkOutput> {
|
||||
): Promise<EmailPasswordResetLinkDTO> {
|
||||
const user = await this.userService.findUserByEmailOrThrow(
|
||||
email,
|
||||
new AuthException('User not found', AuthExceptionCode.INVALID_INPUT),
|
||||
@@ -180,7 +180,7 @@ export class ResetPasswordService {
|
||||
|
||||
async validatePasswordResetToken(
|
||||
resetToken: string,
|
||||
): Promise<ValidatePasswordResetTokenOutput> {
|
||||
): Promise<ValidatePasswordResetTokenDTO> {
|
||||
const hashedResetToken = crypto
|
||||
.createHash('sha256')
|
||||
.update(resetToken)
|
||||
@@ -216,7 +216,7 @@ export class ResetPasswordService {
|
||||
|
||||
async invalidatePasswordResetToken(
|
||||
userId: string,
|
||||
): Promise<InvalidatePasswordOutput> {
|
||||
): Promise<InvalidatePasswordDTO> {
|
||||
const user = await this.userService.findUserByIdOrThrow(
|
||||
userId,
|
||||
new AuthException('User not found', AuthExceptionCode.INVALID_INPUT),
|
||||
|
||||
+15
-7
@@ -259,9 +259,13 @@ describe('JwtAuthStrategy', () => {
|
||||
);
|
||||
|
||||
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
|
||||
new AuthException('User not found', expect.any(String), {
|
||||
userFriendlyMessage: msg`User does not have access to this workspace.`,
|
||||
}),
|
||||
new AuthException(
|
||||
'User or user workspace not found',
|
||||
expect.any(String),
|
||||
{
|
||||
userFriendlyMessage: msg`User does not have access to this workspace`,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
@@ -301,15 +305,19 @@ describe('JwtAuthStrategy', () => {
|
||||
);
|
||||
|
||||
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
|
||||
new AuthException('UserWorkspaceEntity not found', expect.any(String), {
|
||||
userFriendlyMessage: msg`User does not have access to this workspace.`,
|
||||
}),
|
||||
new AuthException(
|
||||
'User or user workspace not found',
|
||||
expect.any(String),
|
||||
{
|
||||
userFriendlyMessage: msg`User does not have access to this workspace`,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
await strategy.validate(payload as JwtPayload);
|
||||
} catch (e) {
|
||||
expect(e.code).toBe(AuthExceptionCode.USER_WORKSPACE_NOT_FOUND);
|
||||
expect(e.code).toBe(AuthExceptionCode.USER_NOT_FOUND);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
+62
-25
@@ -145,17 +145,6 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
);
|
||||
}
|
||||
|
||||
user = await this.userRepository.findOne({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
if (!isDefined(user)) {
|
||||
throw new AuthException(
|
||||
'User not found',
|
||||
AuthExceptionCode.USER_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
if (!payload.userWorkspaceId) {
|
||||
throw new AuthException(
|
||||
'UserWorkspaceEntity not found',
|
||||
@@ -163,29 +152,31 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
);
|
||||
}
|
||||
|
||||
const userWorkspace = await this.userWorkspaceRepository.findOne({
|
||||
where: { id: payload.userWorkspaceId },
|
||||
relations: ['user', 'workspace'],
|
||||
const userContext = await this.resolveUserContext({
|
||||
userId,
|
||||
userWorkspaceId: payload.userWorkspaceId,
|
||||
});
|
||||
|
||||
assertIsDefinedOrThrow(
|
||||
userWorkspace,
|
||||
userContext,
|
||||
new AuthException(
|
||||
'UserWorkspaceEntity not found',
|
||||
AuthExceptionCode.USER_WORKSPACE_NOT_FOUND,
|
||||
'User or user workspace not found',
|
||||
AuthExceptionCode.USER_NOT_FOUND,
|
||||
{
|
||||
userFriendlyMessage: msg`User does not have access to this workspace`,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
user = userContext.user;
|
||||
|
||||
context = {
|
||||
...context,
|
||||
user,
|
||||
workspace,
|
||||
authProvider: payload.authProvider,
|
||||
userWorkspace,
|
||||
userWorkspaceId: userWorkspace.id,
|
||||
userWorkspace: userContext.userWorkspace,
|
||||
userWorkspaceId: userContext.userWorkspace.id,
|
||||
workspaceMemberId: payload.workspaceMemberId,
|
||||
};
|
||||
|
||||
@@ -225,6 +216,41 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveUserContext(params: {
|
||||
userId: string;
|
||||
userWorkspaceId: string;
|
||||
expectedWorkspaceId?: string;
|
||||
}): Promise<{
|
||||
user: UserEntity;
|
||||
userWorkspace: UserWorkspaceEntity;
|
||||
} | null> {
|
||||
const user = await this.userRepository.findOne({
|
||||
where: { id: params.userId },
|
||||
});
|
||||
|
||||
if (!isDefined(user)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const userWorkspace = await this.userWorkspaceRepository.findOne({
|
||||
where: { id: params.userWorkspaceId },
|
||||
relations: ['user', 'workspace'],
|
||||
});
|
||||
|
||||
if (!isDefined(userWorkspace)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
isDefined(params.expectedWorkspaceId) &&
|
||||
userWorkspace.workspace.id !== params.expectedWorkspaceId
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { user, userWorkspace };
|
||||
}
|
||||
|
||||
private async validateImpersonation(payload: AccessTokenJwtPayload) {
|
||||
// Validate required impersonation fields
|
||||
if (
|
||||
@@ -359,12 +385,23 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: Token carries userId/userWorkspaceId but they are unused.
|
||||
// Compute the intersection of user and application permissions instead.
|
||||
return {
|
||||
application,
|
||||
workspace,
|
||||
};
|
||||
const context: AuthContext = { application, workspace };
|
||||
|
||||
if (payload.userId && payload.userWorkspaceId) {
|
||||
const userContext = await this.resolveUserContext({
|
||||
userId: payload.userId,
|
||||
userWorkspaceId: payload.userWorkspaceId,
|
||||
expectedWorkspaceId: workspace.id,
|
||||
});
|
||||
|
||||
if (isDefined(userContext)) {
|
||||
context.user = userContext.user;
|
||||
context.userWorkspace = userContext.userWorkspace;
|
||||
context.userWorkspaceId = userContext.userWorkspace.id;
|
||||
}
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
private isLegacyApiKeyPayload(
|
||||
|
||||
+7
-4
@@ -12,6 +12,7 @@ import {
|
||||
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
|
||||
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { WorkspaceException } from 'src/engine/core-modules/workspace/workspace.exception';
|
||||
|
||||
@@ -43,6 +44,12 @@ describe('ApplicationTokenService', () => {
|
||||
provide: getRepositoryToken(WorkspaceEntity),
|
||||
useClass: Repository,
|
||||
},
|
||||
{
|
||||
provide: TwentyConfigService,
|
||||
useValue: {
|
||||
get: jest.fn().mockReturnValue('1h'),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
@@ -79,7 +86,6 @@ describe('ApplicationTokenService', () => {
|
||||
const result = await service.generateApplicationAccessToken({
|
||||
workspaceId,
|
||||
applicationId,
|
||||
expiresInSeconds: 10,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
@@ -117,7 +123,6 @@ describe('ApplicationTokenService', () => {
|
||||
applicationId,
|
||||
userWorkspaceId,
|
||||
userId,
|
||||
expiresInSeconds: 10,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
@@ -151,7 +156,6 @@ describe('ApplicationTokenService', () => {
|
||||
service.generateApplicationAccessToken({
|
||||
applicationId: 'non-existent-application',
|
||||
workspaceId: 'workspace-id',
|
||||
expiresInSeconds: 10,
|
||||
}),
|
||||
).rejects.toThrow(ApplicationException);
|
||||
});
|
||||
@@ -163,7 +167,6 @@ describe('ApplicationTokenService', () => {
|
||||
service.generateApplicationAccessToken({
|
||||
applicationId: 'application-id',
|
||||
workspaceId: 'non-existent-workspace',
|
||||
expiresInSeconds: 10,
|
||||
}),
|
||||
).rejects.toThrow(WorkspaceException);
|
||||
});
|
||||
|
||||
+32
-27
@@ -24,9 +24,8 @@ import {
|
||||
AuthException,
|
||||
AuthExceptionCode,
|
||||
} from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
|
||||
const APPLICATION_ACCESS_TOKEN_EXPIRY_SECONDS = 1800;
|
||||
const APPLICATION_REFRESH_TOKEN_EXPIRY_SECONDS = 60 * 60 * 24 * 60; // 60 days
|
||||
const APPLICATION_REFRESH_TOKEN_INVALID_OR_EXPIRED_MESSAGE =
|
||||
'Application refresh token invalid or expired';
|
||||
|
||||
@@ -39,6 +38,7 @@ export class ApplicationTokenService {
|
||||
private readonly workspaceRepository: Repository<WorkspaceEntity>,
|
||||
@InjectRepository(ApplicationEntity)
|
||||
private readonly applicationRepository: Repository<ApplicationEntity>,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
) {}
|
||||
|
||||
async generateApplicationAccessToken({
|
||||
@@ -46,23 +46,25 @@ export class ApplicationTokenService {
|
||||
applicationId,
|
||||
userWorkspaceId,
|
||||
userId,
|
||||
expiresInSeconds = APPLICATION_ACCESS_TOKEN_EXPIRY_SECONDS,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
applicationId: string;
|
||||
userWorkspaceId?: string;
|
||||
userId?: string;
|
||||
expiresInSeconds?: number;
|
||||
}): Promise<AuthToken> {
|
||||
await this.validateWorkspaceAndApplication(workspaceId, applicationId);
|
||||
|
||||
const expiresIn = this.twentyConfigService.get(
|
||||
'APPLICATION_ACCESS_TOKEN_EXPIRES_IN',
|
||||
);
|
||||
|
||||
return this.signApplicationToken({
|
||||
workspaceId,
|
||||
applicationId,
|
||||
userWorkspaceId,
|
||||
userId,
|
||||
tokenType: JwtTokenTypeEnum.APPLICATION_ACCESS,
|
||||
expiresInSeconds,
|
||||
expiresIn,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -82,26 +84,30 @@ export class ApplicationTokenService {
|
||||
}> {
|
||||
await this.validateWorkspaceAndApplication(workspaceId, applicationId);
|
||||
|
||||
const [applicationAccessToken, applicationRefreshToken] = await Promise.all(
|
||||
[
|
||||
this.signApplicationToken({
|
||||
workspaceId,
|
||||
applicationId,
|
||||
userWorkspaceId,
|
||||
userId,
|
||||
tokenType: JwtTokenTypeEnum.APPLICATION_ACCESS,
|
||||
expiresInSeconds: APPLICATION_ACCESS_TOKEN_EXPIRY_SECONDS,
|
||||
}),
|
||||
this.signApplicationToken({
|
||||
workspaceId,
|
||||
applicationId,
|
||||
userWorkspaceId,
|
||||
userId,
|
||||
tokenType: JwtTokenTypeEnum.APPLICATION_REFRESH,
|
||||
expiresInSeconds: APPLICATION_REFRESH_TOKEN_EXPIRY_SECONDS,
|
||||
}),
|
||||
],
|
||||
const accessTokenExpiresIn = this.twentyConfigService.get(
|
||||
'APPLICATION_ACCESS_TOKEN_EXPIRES_IN',
|
||||
);
|
||||
const refreshTokenExpiresIn = this.twentyConfigService.get(
|
||||
'APPLICATION_REFRESH_TOKEN_EXPIRES_IN',
|
||||
);
|
||||
|
||||
const applicationAccessToken = this.signApplicationToken({
|
||||
workspaceId,
|
||||
applicationId,
|
||||
userWorkspaceId,
|
||||
userId,
|
||||
tokenType: JwtTokenTypeEnum.APPLICATION_ACCESS,
|
||||
expiresIn: accessTokenExpiresIn,
|
||||
});
|
||||
|
||||
const applicationRefreshToken = this.signApplicationToken({
|
||||
workspaceId,
|
||||
applicationId,
|
||||
userWorkspaceId,
|
||||
userId,
|
||||
tokenType: JwtTokenTypeEnum.APPLICATION_REFRESH,
|
||||
expiresIn: refreshTokenExpiresIn,
|
||||
});
|
||||
|
||||
return { applicationAccessToken, applicationRefreshToken };
|
||||
}
|
||||
@@ -188,7 +194,7 @@ export class ApplicationTokenService {
|
||||
userWorkspaceId,
|
||||
userId,
|
||||
tokenType,
|
||||
expiresInSeconds,
|
||||
expiresIn,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
applicationId: string;
|
||||
@@ -197,9 +203,8 @@ export class ApplicationTokenService {
|
||||
tokenType:
|
||||
| JwtTokenTypeEnum.APPLICATION_ACCESS
|
||||
| JwtTokenTypeEnum.APPLICATION_REFRESH;
|
||||
expiresInSeconds: number;
|
||||
expiresIn: string;
|
||||
}): AuthToken {
|
||||
const expiresIn = `${expiresInSeconds}s`;
|
||||
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
|
||||
|
||||
const jwtPayload:
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
// RFC 6749 redirect URI validation: must be absolute, HTTPS (except localhost), no fragments
|
||||
export const validateRedirectUri = (
|
||||
uri: string,
|
||||
): { valid: true; parsed: URL } | { valid: false; reason: string } => {
|
||||
let parsed: URL;
|
||||
|
||||
try {
|
||||
parsed = new URL(uri);
|
||||
} catch {
|
||||
return { valid: false, reason: `Invalid redirect URI: ${uri}` };
|
||||
}
|
||||
|
||||
const isLocalhost =
|
||||
parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1';
|
||||
|
||||
if (parsed.protocol !== 'https:' && !isLocalhost) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: `Redirect URIs must use HTTPS (except localhost): ${uri}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (parsed.hash) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: `Redirect URIs must not contain fragments: ${uri}`,
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true, parsed };
|
||||
};
|
||||
@@ -10,11 +10,11 @@ import { type ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entit
|
||||
import { BillingCheckoutSessionInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-checkout-session.input';
|
||||
import { BillingSessionInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-session.input';
|
||||
import { BillingUpdateSubscriptionItemPriceInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-update-subscription-item-price.input';
|
||||
import { BillingEndTrialPeriodOutput } from 'src/engine/core-modules/billing/dtos/outputs/billing-end-trial-period.output';
|
||||
import { BillingMeteredProductUsageOutput } from 'src/engine/core-modules/billing/dtos/outputs/billing-metered-product-usage.output';
|
||||
import { BillingPlanOutput } from 'src/engine/core-modules/billing/dtos/outputs/billing-plan.output';
|
||||
import { BillingSessionOutput } from 'src/engine/core-modules/billing/dtos/outputs/billing-session.output';
|
||||
import { BillingUpdateOutput } from 'src/engine/core-modules/billing/dtos/outputs/billing-update.output';
|
||||
import { BillingEndTrialPeriodDTO } from 'src/engine/core-modules/billing/dtos/billing-end-trial-period.dto';
|
||||
import { BillingMeteredProductUsageDTO } from 'src/engine/core-modules/billing/dtos/billing-metered-product-usage.dto';
|
||||
import { BillingPlanDTO } from 'src/engine/core-modules/billing/dtos/billing-plan.dto';
|
||||
import { BillingSessionDTO } from 'src/engine/core-modules/billing/dtos/billing-session.dto';
|
||||
import { BillingUpdateDTO } from 'src/engine/core-modules/billing/dtos/billing-update.dto';
|
||||
import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum';
|
||||
import { BillingPlanService } from 'src/engine/core-modules/billing/services/billing-plan.service';
|
||||
import { BillingPortalWorkspaceService } from 'src/engine/core-modules/billing/services/billing-portal.workspace-service';
|
||||
@@ -65,7 +65,7 @@ export class BillingResolver {
|
||||
private readonly permissionsService: PermissionsService,
|
||||
) {}
|
||||
|
||||
@Query(() => BillingSessionOutput)
|
||||
@Query(() => BillingSessionDTO)
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.BILLING),
|
||||
@@ -82,7 +82,7 @@ export class BillingResolver {
|
||||
};
|
||||
}
|
||||
|
||||
@Mutation(() => BillingSessionOutput)
|
||||
@Mutation(() => BillingSessionDTO)
|
||||
@UseGuards(WorkspaceAuthGuard, UserAuthGuard, NoPermissionGuard)
|
||||
async checkoutSession(
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
@@ -143,7 +143,7 @@ export class BillingResolver {
|
||||
}
|
||||
}
|
||||
|
||||
@Mutation(() => BillingUpdateOutput)
|
||||
@Mutation(() => BillingUpdateDTO)
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.BILLING),
|
||||
@@ -165,7 +165,7 @@ export class BillingResolver {
|
||||
};
|
||||
}
|
||||
|
||||
@Mutation(() => BillingUpdateOutput)
|
||||
@Mutation(() => BillingUpdateDTO)
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.BILLING),
|
||||
@@ -185,7 +185,7 @@ export class BillingResolver {
|
||||
};
|
||||
}
|
||||
|
||||
@Mutation(() => BillingUpdateOutput)
|
||||
@Mutation(() => BillingUpdateDTO)
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.BILLING),
|
||||
@@ -205,7 +205,7 @@ export class BillingResolver {
|
||||
};
|
||||
}
|
||||
|
||||
@Mutation(() => BillingUpdateOutput)
|
||||
@Mutation(() => BillingUpdateDTO)
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.BILLING),
|
||||
@@ -229,7 +229,7 @@ export class BillingResolver {
|
||||
};
|
||||
}
|
||||
|
||||
@Mutation(() => BillingUpdateOutput)
|
||||
@Mutation(() => BillingUpdateDTO)
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.BILLING),
|
||||
@@ -255,22 +255,22 @@ export class BillingResolver {
|
||||
};
|
||||
}
|
||||
|
||||
@Query(() => [BillingPlanOutput])
|
||||
@Query(() => [BillingPlanDTO])
|
||||
@UseGuards(WorkspaceAuthGuard, NoPermissionGuard)
|
||||
async listPlans(): Promise<BillingPlanOutput[]> {
|
||||
async listPlans(): Promise<BillingPlanDTO[]> {
|
||||
const plans = await this.billingPlanService.listPlans();
|
||||
|
||||
return plans.map(formatBillingDatabaseProductToGraphqlDTO);
|
||||
}
|
||||
|
||||
@Mutation(() => BillingEndTrialPeriodOutput)
|
||||
@Mutation(() => BillingEndTrialPeriodDTO)
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.BILLING),
|
||||
)
|
||||
async endSubscriptionTrialPeriod(
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
): Promise<BillingEndTrialPeriodOutput> {
|
||||
): Promise<BillingEndTrialPeriodDTO> {
|
||||
const result =
|
||||
await this.billingSubscriptionService.endTrialPeriod(workspace);
|
||||
|
||||
@@ -295,14 +295,14 @@ export class BillingResolver {
|
||||
};
|
||||
}
|
||||
|
||||
@Query(() => [BillingMeteredProductUsageOutput])
|
||||
@Query(() => [BillingMeteredProductUsageDTO])
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.BILLING),
|
||||
)
|
||||
async getMeteredProductsUsage(
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
): Promise<BillingMeteredProductUsageOutput[]> {
|
||||
): Promise<BillingMeteredProductUsageDTO[]> {
|
||||
const usageData =
|
||||
await this.billingUsageService.getMeteredProductsUsage(workspace);
|
||||
|
||||
@@ -316,7 +316,7 @@ export class BillingResolver {
|
||||
}));
|
||||
}
|
||||
|
||||
@Mutation(() => BillingUpdateOutput)
|
||||
@Mutation(() => BillingUpdateDTO)
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.BILLING),
|
||||
|
||||
+2
-2
@@ -4,8 +4,8 @@ import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
|
||||
|
||||
@ObjectType()
|
||||
export class BillingEndTrialPeriodOutput {
|
||||
@ObjectType('BillingEndTrialPeriod')
|
||||
export class BillingEndTrialPeriodDTO {
|
||||
@Field(() => SubscriptionStatus, {
|
||||
description: 'Updated subscription status',
|
||||
nullable: true,
|
||||
+2
-2
@@ -2,8 +2,8 @@ import { Field, Float, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
|
||||
|
||||
@ObjectType()
|
||||
export class BillingMeteredProductUsageOutput {
|
||||
@ObjectType('BillingMeteredProductUsage')
|
||||
export class BillingMeteredProductUsageDTO {
|
||||
@Field(() => BillingProductKey)
|
||||
productKey: BillingProductKey;
|
||||
|
||||
+2
-2
@@ -8,8 +8,8 @@ import {
|
||||
} from 'src/engine/core-modules/billing/dtos/billing-product.dto';
|
||||
import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum';
|
||||
|
||||
@ObjectType()
|
||||
export class BillingPlanOutput {
|
||||
@ObjectType('BillingPlan')
|
||||
export class BillingPlanDTO {
|
||||
@Field(() => BillingPlanKey)
|
||||
planKey: BillingPlanKey;
|
||||
|
||||
+2
-2
@@ -4,8 +4,8 @@ import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
|
||||
|
||||
@ObjectType()
|
||||
export class BillingPriceOutput {
|
||||
@ObjectType('BillingPrice')
|
||||
export class BillingPriceDTO {
|
||||
@Field(() => Number)
|
||||
upTo: number;
|
||||
|
||||
+2
-2
@@ -2,8 +2,8 @@
|
||||
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType()
|
||||
export class BillingSessionOutput {
|
||||
@ObjectType('BillingSession')
|
||||
export class BillingSessionDTO {
|
||||
@Field(() => String, { nullable: true })
|
||||
url: string;
|
||||
}
|
||||
+1
-1
@@ -7,7 +7,7 @@ import { IDField } from '@ptc-org/nestjs-query-graphql';
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { BillingProductDTO } from 'src/engine/core-modules/billing/dtos/billing-product.dto';
|
||||
|
||||
@ObjectType()
|
||||
@ObjectType('BillingSubscriptionItem')
|
||||
export class BillingSubscriptionItemDTO {
|
||||
@IDField(() => UUIDScalarType)
|
||||
id: string;
|
||||
+2
-2
@@ -4,8 +4,8 @@ import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
|
||||
|
||||
@ObjectType()
|
||||
export class BillingUpdateOutput {
|
||||
@ObjectType('BillingUpdate')
|
||||
export class BillingUpdateDTO {
|
||||
@Field(() => BillingSubscriptionEntity, {
|
||||
description: 'Current billing subscription',
|
||||
})
|
||||
+1
-1
@@ -38,7 +38,7 @@ export type CancellationDetailsJson = {
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { BillingSubscriptionSchedulePhaseDTO } from 'src/engine/core-modules/billing/dtos/billing-subscription-schedule-phase.dto';
|
||||
import { BillingSubscriptionItemDTO } from 'src/engine/core-modules/billing/dtos/outputs/billing-subscription-item.output';
|
||||
import { BillingSubscriptionItemDTO } from 'src/engine/core-modules/billing/dtos/billing-subscription-item.dto';
|
||||
import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/billing-customer.entity';
|
||||
import { BillingSubscriptionItemEntity } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
|
||||
import { BillingSubscriptionCollectionMethod } from 'src/engine/core-modules/billing/enums/billing-subscription-collection-method.enum';
|
||||
|
||||
+3
-3
@@ -10,7 +10,7 @@ import {
|
||||
BillingException,
|
||||
BillingExceptionCode,
|
||||
} from 'src/engine/core-modules/billing/billing.exception';
|
||||
import { type BillingMeteredProductUsageOutput } from 'src/engine/core-modules/billing/dtos/outputs/billing-metered-product-usage.output';
|
||||
import { type BillingMeteredProductUsageDTO } from 'src/engine/core-modules/billing/dtos/billing-metered-product-usage.dto';
|
||||
import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/billing-customer.entity';
|
||||
import { BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
|
||||
import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
|
||||
@@ -90,7 +90,7 @@ export class BillingUsageService {
|
||||
|
||||
async getMeteredProductsUsage(
|
||||
workspace: WorkspaceEntity,
|
||||
): Promise<BillingMeteredProductUsageOutput[]> {
|
||||
): Promise<BillingMeteredProductUsageDTO[]> {
|
||||
const subscription =
|
||||
await this.billingSubscriptionService.getCurrentBillingSubscriptionOrThrow(
|
||||
{ workspaceId: workspace.id },
|
||||
@@ -146,7 +146,7 @@ export class BillingUsageService {
|
||||
>[number],
|
||||
periodStart: Date,
|
||||
periodEnd: Date,
|
||||
): Promise<BillingMeteredProductUsageOutput> {
|
||||
): Promise<BillingMeteredProductUsageDTO> {
|
||||
const meterEventsSum =
|
||||
await this.stripeBillingMeterEventService.sumMeterEvents(
|
||||
item.stripeMeterId,
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
import { type BillingPriceLicensedDTO } from 'src/engine/core-modules/billing/dtos/billing-price-licensed.dto';
|
||||
import { type BillingPriceMeteredDTO } from 'src/engine/core-modules/billing/dtos/billing-price-metered.dto';
|
||||
import { type BillingPlanOutput } from 'src/engine/core-modules/billing/dtos/outputs/billing-plan.output';
|
||||
import { type BillingPlanDTO } from 'src/engine/core-modules/billing/dtos/billing-plan.dto';
|
||||
import { type BillingPriceEntity } from 'src/engine/core-modules/billing/entities/billing-price.entity';
|
||||
import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
|
||||
import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum';
|
||||
@@ -11,7 +11,7 @@ import { toDisplayCredits } from 'src/engine/core-modules/billing/utils/to-displ
|
||||
|
||||
export const formatBillingDatabaseProductToGraphqlDTO = (
|
||||
plan: BillingGetPlanResult,
|
||||
): BillingPlanOutput => {
|
||||
): BillingPlanDTO => {
|
||||
return {
|
||||
planKey: plan.planKey,
|
||||
licensedProducts: plan.licensedProducts.map((product) => {
|
||||
|
||||
+3
-3
@@ -5,7 +5,7 @@ import { SupportDriver } from 'src/engine/core-modules/twenty-config/interfaces/
|
||||
import { BillingTrialPeriodDTO } from 'src/engine/core-modules/billing/dtos/billing-trial-period.dto';
|
||||
import { CaptchaDriverType } from 'src/engine/core-modules/captcha/interfaces';
|
||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||
import { AuthProvidersDTO } from 'src/engine/core-modules/workspace/dtos/public-workspace-data-output';
|
||||
import { AuthProvidersDTO } from 'src/engine/core-modules/workspace/dtos/public-workspace-data.dto';
|
||||
import {
|
||||
InferenceProvider,
|
||||
ModelFamily,
|
||||
@@ -90,8 +90,8 @@ export class AdminAIModelConfig {
|
||||
isRecommended?: boolean;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class AdminAIModelsOutput {
|
||||
@ObjectType('AdminAIModels')
|
||||
export class AdminAIModelsDTO {
|
||||
@Field(() => Boolean)
|
||||
autoEnableNewModels: boolean;
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ActorModule } from 'src/engine/core-modules/actor/actor.module';
|
||||
import { AdminPanelModule } from 'src/engine/core-modules/admin-panel/admin-panel.module';
|
||||
import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module';
|
||||
import { AppTokenModule } from 'src/engine/core-modules/app-token/app-token.module';
|
||||
import { ApplicationRegistrationModule } from 'src/engine/core-modules/application-registration/application-registration.module';
|
||||
import { ApplicationSyncModule } from 'src/engine/core-modules/application/application-sync.module';
|
||||
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
|
||||
import { EnvironmentModule } from 'src/engine/core-modules/environment/environment.module';
|
||||
@@ -88,6 +89,7 @@ import { FileModule } from './file/file.module';
|
||||
FileModule,
|
||||
RowLevelPermissionModule,
|
||||
OpenApiModule,
|
||||
ApplicationRegistrationModule,
|
||||
ApplicationModule,
|
||||
ApplicationSyncModule,
|
||||
AppTokenModule,
|
||||
|
||||
+2
-2
@@ -2,8 +2,8 @@ import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { IsBoolean } from 'class-validator';
|
||||
|
||||
@ObjectType()
|
||||
export class ResendEmailVerificationTokenOutput {
|
||||
@ObjectType('ResendEmailVerificationToken')
|
||||
export class ResendEmailVerificationTokenDTO {
|
||||
@IsBoolean()
|
||||
@Field(() => Boolean)
|
||||
success: boolean;
|
||||
+3
-3
@@ -4,7 +4,7 @@ import { Args, Context, Mutation } from '@nestjs/graphql';
|
||||
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
|
||||
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
|
||||
import { ResendEmailVerificationTokenInput } from 'src/engine/core-modules/email-verification/dtos/resend-email-verification-token.input';
|
||||
import { ResendEmailVerificationTokenOutput } from 'src/engine/core-modules/email-verification/dtos/resend-email-verification-token.output';
|
||||
import { ResendEmailVerificationTokenDTO } from 'src/engine/core-modules/email-verification/dtos/resend-email-verification-token.dto';
|
||||
import { EmailVerificationExceptionFilter } from 'src/engine/core-modules/email-verification/email-verification-exception-filter.util';
|
||||
import { EmailVerificationService } from 'src/engine/core-modules/email-verification/services/email-verification.service';
|
||||
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
|
||||
@@ -26,14 +26,14 @@ export class EmailVerificationResolver {
|
||||
) {}
|
||||
|
||||
// TODO: this should be an authenticated endpoint
|
||||
@Mutation(() => ResendEmailVerificationTokenOutput)
|
||||
@Mutation(() => ResendEmailVerificationTokenDTO)
|
||||
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
|
||||
async resendEmailVerificationToken(
|
||||
@Args()
|
||||
resendEmailVerificationTokenInput: ResendEmailVerificationTokenInput,
|
||||
@Args('origin') origin: string,
|
||||
@Context() context: I18nContext,
|
||||
): Promise<ResendEmailVerificationTokenOutput> {
|
||||
): Promise<ResendEmailVerificationTokenDTO> {
|
||||
const workspace =
|
||||
await this.workspaceDomainsService.getWorkspaceByOriginOrDefaultWorkspace(
|
||||
origin,
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ registerEnumType(EmailingDomainStatus, {
|
||||
});
|
||||
|
||||
@ObjectType('EmailingDomain')
|
||||
export class EmailingDomainDto {
|
||||
export class EmailingDomainDTO {
|
||||
@IDField(() => UUIDScalarType)
|
||||
id: string;
|
||||
|
||||
|
||||
+8
-8
@@ -5,7 +5,7 @@ import { PermissionFlagType } from 'twenty-shared/constants';
|
||||
|
||||
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
|
||||
import { EmailingDomainDriver } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain';
|
||||
import { EmailingDomainDto } from 'src/engine/core-modules/emailing-domain/dtos/emailing-domain.dto';
|
||||
import { EmailingDomainDTO } from 'src/engine/core-modules/emailing-domain/dtos/emailing-domain.dto';
|
||||
import { EmailingDomainService } from 'src/engine/core-modules/emailing-domain/services/emailing-domain.service';
|
||||
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
@@ -18,16 +18,16 @@ import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
SettingsPermissionGuard(PermissionFlagType.WORKSPACE),
|
||||
)
|
||||
@UsePipes(ResolverValidationPipe)
|
||||
@MetadataResolver(() => EmailingDomainDto)
|
||||
@MetadataResolver(() => EmailingDomainDTO)
|
||||
export class EmailingDomainResolver {
|
||||
constructor(private readonly emailingDomainService: EmailingDomainService) {}
|
||||
|
||||
@Mutation(() => EmailingDomainDto)
|
||||
@Mutation(() => EmailingDomainDTO)
|
||||
async createEmailingDomain(
|
||||
@Args('domain') domain: string,
|
||||
@Args('driver') driver: EmailingDomainDriver,
|
||||
@AuthWorkspace() currentWorkspace: WorkspaceEntity,
|
||||
): Promise<EmailingDomainDto> {
|
||||
): Promise<EmailingDomainDTO> {
|
||||
const emailingDomain =
|
||||
await this.emailingDomainService.createEmailingDomain(
|
||||
domain,
|
||||
@@ -48,11 +48,11 @@ export class EmailingDomainResolver {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Mutation(() => EmailingDomainDto)
|
||||
@Mutation(() => EmailingDomainDTO)
|
||||
async verifyEmailingDomain(
|
||||
@Args('id') id: string,
|
||||
@AuthWorkspace() currentWorkspace: WorkspaceEntity,
|
||||
): Promise<EmailingDomainDto> {
|
||||
): Promise<EmailingDomainDTO> {
|
||||
const emailingDomain =
|
||||
await this.emailingDomainService.verifyEmailingDomain(
|
||||
currentWorkspace,
|
||||
@@ -62,10 +62,10 @@ export class EmailingDomainResolver {
|
||||
return emailingDomain;
|
||||
}
|
||||
|
||||
@Query(() => [EmailingDomainDto])
|
||||
@Query(() => [EmailingDomainDTO])
|
||||
async getEmailingDomains(
|
||||
@AuthWorkspace() currentWorkspace: WorkspaceEntity,
|
||||
): Promise<EmailingDomainDto[]> {
|
||||
): Promise<EmailingDomainDTO[]> {
|
||||
const emailingDomains =
|
||||
await this.emailingDomainService.getEmailingDomains(currentWorkspace);
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-module
|
||||
import { EventLogsService } from './event-logs.service';
|
||||
|
||||
import { EventLogQueryInput } from './dtos/event-log-query.input';
|
||||
import { EventLogQueryResult } from './dtos/event-log-result.output';
|
||||
import { EventLogQueryResult } from './dtos/event-log-result.dto';
|
||||
|
||||
@MetadataResolver()
|
||||
@UseFilters(
|
||||
|
||||
@@ -23,7 +23,7 @@ import { EventLogQueryInput } from './dtos/event-log-query.input';
|
||||
import {
|
||||
EventLogQueryResult,
|
||||
EventLogRecord,
|
||||
} from './dtos/event-log-result.output';
|
||||
} from './dtos/event-log-result.dto';
|
||||
|
||||
type ClickHouseEventRecord = {
|
||||
event?: string;
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ import { Column } from 'typeorm';
|
||||
|
||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||
|
||||
@ObjectType('FeatureFlagDTO')
|
||||
@ObjectType('FeatureFlag')
|
||||
export class FeatureFlagDTO {
|
||||
@Field(() => FeatureFlagKey)
|
||||
@Column({ nullable: false, type: 'text' })
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
|
||||
import { registerEnumType } from '@nestjs/graphql';
|
||||
|
||||
import { IDField } from '@ptc-org/nestjs-query-graphql';
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
@@ -10,23 +9,18 @@ import {
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||
import { WorkspaceRelatedEntity } from 'src/engine/workspace-manager/types/workspace-related-entity';
|
||||
|
||||
@Entity({ name: 'featureFlag', schema: 'core' })
|
||||
@ObjectType('FeatureFlag')
|
||||
@Unique('IDX_FEATURE_FLAG_KEY_WORKSPACE_ID_UNIQUE', ['key', 'workspaceId'])
|
||||
export class FeatureFlagEntity extends WorkspaceRelatedEntity {
|
||||
@IDField(() => UUIDScalarType)
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Field(() => FeatureFlagKey)
|
||||
@Column({ nullable: false, type: 'text' })
|
||||
key: FeatureFlagKey;
|
||||
|
||||
@Field()
|
||||
@Column({ nullable: false })
|
||||
value: boolean;
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { NestjsQueryGraphQLModule } from '@ptc-org/nestjs-query-graphql';
|
||||
import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
|
||||
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
|
||||
@@ -12,11 +10,7 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
|
||||
@Module({
|
||||
imports: [
|
||||
TypeORMModule,
|
||||
NestjsQueryGraphQLModule.forFeature({
|
||||
imports: [NestjsQueryTypeOrmModule.forFeature([FeatureFlagEntity])],
|
||||
services: [],
|
||||
resolvers: [],
|
||||
}),
|
||||
TypeOrmModule.forFeature([FeatureFlagEntity]),
|
||||
WorkspaceFeatureFlagsMapCacheModule,
|
||||
WorkspaceCacheModule,
|
||||
],
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ import { Repository } from 'typeorm';
|
||||
|
||||
import { type FeatureFlagMap } from 'src/engine/core-modules/feature-flag/interfaces/feature-flag-map.interface';
|
||||
|
||||
import { type FeatureFlagDTO } from 'src/engine/core-modules/feature-flag/dtos/feature-flag-dto';
|
||||
import { type FeatureFlagDTO } from 'src/engine/core-modules/feature-flag/dtos/feature-flag.dto';
|
||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
|
||||
import {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Field, ObjectType } from '@nestjs/graphql';
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
|
||||
@ObjectType('FileWithSignedUrl')
|
||||
export class FileWithSignedUrlDto {
|
||||
export class FileWithSignedUrlDTO {
|
||||
@Field(() => UUIDScalarType)
|
||||
id: string;
|
||||
|
||||
|
||||
+5
-5
@@ -7,7 +7,7 @@ import { PermissionFlagType } from 'twenty-shared/constants';
|
||||
import type { FileUpload } from 'graphql-upload/processRequest.mjs';
|
||||
|
||||
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
|
||||
import { FileWithSignedUrlDto } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto';
|
||||
import { FileWithSignedUrlDTO } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto';
|
||||
import { FileCorePictureService } from 'src/engine/core-modules/file/file-core-picture/services/file-core-picture.service';
|
||||
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';
|
||||
@@ -31,7 +31,7 @@ export class FileCorePictureResolver {
|
||||
private readonly fileCorePictureService: FileCorePictureService,
|
||||
) {}
|
||||
|
||||
@Mutation(() => FileWithSignedUrlDto)
|
||||
@Mutation(() => FileWithSignedUrlDTO)
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.WORKSPACE),
|
||||
@@ -40,7 +40,7 @@ export class FileCorePictureResolver {
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
@Args({ name: 'file', type: () => GraphQLUpload })
|
||||
{ createReadStream, filename }: FileUpload,
|
||||
): Promise<FileWithSignedUrlDto> {
|
||||
): Promise<FileWithSignedUrlDTO> {
|
||||
const buffer = await streamToBuffer(createReadStream());
|
||||
|
||||
return await this.fileCorePictureService.uploadWorkspacePicture({
|
||||
@@ -50,13 +50,13 @@ export class FileCorePictureResolver {
|
||||
});
|
||||
}
|
||||
|
||||
@Mutation(() => FileWithSignedUrlDto)
|
||||
@Mutation(() => FileWithSignedUrlDTO)
|
||||
@UseGuards(WorkspaceAuthGuard, UploadProfilePicturePermissionGuard)
|
||||
async uploadWorkspaceMemberProfilePicture(
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
@Args({ name: 'file', type: () => GraphQLUpload })
|
||||
{ createReadStream, filename }: FileUpload,
|
||||
): Promise<FileWithSignedUrlDto> {
|
||||
): Promise<FileWithSignedUrlDTO> {
|
||||
const buffer = await streamToBuffer(createReadStream());
|
||||
|
||||
return await this.fileCorePictureService.uploadWorkspaceMemberProfilePicture(
|
||||
|
||||
+5
-5
@@ -12,7 +12,7 @@ import { v4 } from 'uuid';
|
||||
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
import { FileWithSignedUrlDto } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto';
|
||||
import { FileWithSignedUrlDTO } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto';
|
||||
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
|
||||
import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
|
||||
import { extractFileInfo } from 'src/engine/core-modules/file/utils/extract-file-info.utils';
|
||||
@@ -88,7 +88,7 @@ export class FileCorePictureService {
|
||||
file: Buffer;
|
||||
filename: string;
|
||||
workspace: WorkspaceEntity;
|
||||
}): Promise<FileWithSignedUrlDto> {
|
||||
}): Promise<FileWithSignedUrlDTO> {
|
||||
const savedFile = await this.uploadCorePicture({
|
||||
file,
|
||||
filename,
|
||||
@@ -130,7 +130,7 @@ export class FileCorePictureService {
|
||||
workspaceId: string;
|
||||
applicationUniversalIdentifier?: string;
|
||||
queryRunner?: QueryRunner;
|
||||
}): Promise<FileWithSignedUrlDto> {
|
||||
}): Promise<FileWithSignedUrlDTO> {
|
||||
const savedFile = await this.uploadCorePicture({
|
||||
file,
|
||||
filename,
|
||||
@@ -211,7 +211,7 @@ export class FileCorePictureService {
|
||||
workspaceId: string;
|
||||
applicationUniversalIdentifier?: string;
|
||||
queryRunner?: QueryRunner;
|
||||
}): Promise<FileWithSignedUrlDto | undefined> {
|
||||
}): Promise<FileWithSignedUrlDTO | undefined> {
|
||||
const imageData = await this.fetchImageBufferFromUrl(imageUrl);
|
||||
|
||||
if (!isDefined(imageData)) {
|
||||
@@ -265,7 +265,7 @@ export class FileCorePictureService {
|
||||
targetWorkspaceId: string;
|
||||
targetApplicationUniversalIdentifier?: string;
|
||||
queryRunner?: QueryRunner;
|
||||
}): Promise<FileWithSignedUrlDto> {
|
||||
}): Promise<FileWithSignedUrlDTO> {
|
||||
const sourceFile = await this.fileRepository.findOneOrFail({
|
||||
where: {
|
||||
id: sourceFileId,
|
||||
|
||||
+3
-3
@@ -7,7 +7,7 @@ import { PermissionFlagType } from 'twenty-shared/constants';
|
||||
import type { FileUpload } from 'graphql-upload/processRequest.mjs';
|
||||
|
||||
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
|
||||
import { FileWithSignedUrlDto } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto';
|
||||
import { FileWithSignedUrlDTO } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto';
|
||||
import { FileWorkflowService } from 'src/engine/core-modules/file/file-workflow/services/file-workflow.service';
|
||||
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';
|
||||
@@ -24,14 +24,14 @@ import { streamToBuffer } from 'src/utils/stream-to-buffer';
|
||||
export class FileWorkflowResolver {
|
||||
constructor(private readonly fileWorkflowService: FileWorkflowService) {}
|
||||
|
||||
@Mutation(() => FileWithSignedUrlDto)
|
||||
@Mutation(() => FileWithSignedUrlDTO)
|
||||
@UseGuards(SettingsPermissionGuard(PermissionFlagType.UPLOAD_FILE))
|
||||
async uploadWorkflowFile(
|
||||
@AuthWorkspace()
|
||||
{ id: workspaceId }: WorkspaceEntity,
|
||||
@Args({ name: 'file', type: () => GraphQLUpload })
|
||||
{ createReadStream, filename }: FileUpload,
|
||||
): Promise<FileWithSignedUrlDto> {
|
||||
): Promise<FileWithSignedUrlDTO> {
|
||||
const stream = createReadStream();
|
||||
const buffer = await streamToBuffer(stream);
|
||||
|
||||
|
||||
+2
-2
@@ -6,7 +6,7 @@ import { v4 } from 'uuid';
|
||||
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
import { FileWithSignedUrlDto } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto';
|
||||
import { FileWithSignedUrlDTO } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto';
|
||||
import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
|
||||
import { extractFileInfo } from 'src/engine/core-modules/file/utils/extract-file-info.utils';
|
||||
import { sanitizeFile } from 'src/engine/core-modules/file/utils/sanitize-file.utils';
|
||||
@@ -27,7 +27,7 @@ export class FileWorkflowService {
|
||||
file: Buffer;
|
||||
filename: string;
|
||||
workspaceId: string;
|
||||
}): Promise<FileWithSignedUrlDto> {
|
||||
}): Promise<FileWithSignedUrlDTO> {
|
||||
const { mimeType, ext } = await extractFileInfo({
|
||||
file,
|
||||
filename,
|
||||
|
||||
+5
-5
@@ -7,7 +7,7 @@ import { PermissionFlagType } from 'twenty-shared/constants';
|
||||
import type { FileUpload } from 'graphql-upload/processRequest.mjs';
|
||||
|
||||
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
|
||||
import { FileWithSignedUrlDto } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto';
|
||||
import { FileWithSignedUrlDTO } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto';
|
||||
import { FilesFieldService } from 'src/engine/core-modules/file/files-field/services/files-field.service';
|
||||
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';
|
||||
@@ -24,7 +24,7 @@ import { streamToBuffer } from 'src/utils/stream-to-buffer';
|
||||
export class FilesFieldResolver {
|
||||
constructor(private readonly filesFieldService: FilesFieldService) {}
|
||||
|
||||
@Mutation(() => FileWithSignedUrlDto)
|
||||
@Mutation(() => FileWithSignedUrlDTO)
|
||||
@UseGuards(SettingsPermissionGuard(PermissionFlagType.UPLOAD_FILE))
|
||||
async uploadFilesFieldFile(
|
||||
@AuthWorkspace()
|
||||
@@ -37,7 +37,7 @@ export class FilesFieldResolver {
|
||||
nullable: false,
|
||||
})
|
||||
fieldMetadataId: string,
|
||||
): Promise<FileWithSignedUrlDto> {
|
||||
): Promise<FileWithSignedUrlDTO> {
|
||||
const stream = createReadStream();
|
||||
const buffer = await streamToBuffer(stream);
|
||||
|
||||
@@ -49,7 +49,7 @@ export class FilesFieldResolver {
|
||||
});
|
||||
}
|
||||
|
||||
@Mutation(() => FileWithSignedUrlDto)
|
||||
@Mutation(() => FileWithSignedUrlDTO)
|
||||
@UseGuards(SettingsPermissionGuard(PermissionFlagType.UPLOAD_FILE))
|
||||
async uploadFilesFieldFileByUniversalIdentifier(
|
||||
@AuthWorkspace()
|
||||
@@ -62,7 +62,7 @@ export class FilesFieldResolver {
|
||||
nullable: false,
|
||||
})
|
||||
fieldMetadataUniversalIdentifier: string,
|
||||
): Promise<FileWithSignedUrlDto> {
|
||||
): Promise<FileWithSignedUrlDTO> {
|
||||
const stream = createReadStream();
|
||||
const buffer = await streamToBuffer(stream);
|
||||
|
||||
|
||||
+2
-2
@@ -9,7 +9,7 @@ import { v4 } from 'uuid';
|
||||
|
||||
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
import { FileWithSignedUrlDto } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto';
|
||||
import { FileWithSignedUrlDTO } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto';
|
||||
import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
|
||||
import {
|
||||
FilesFieldException,
|
||||
@@ -42,7 +42,7 @@ export class FilesFieldService {
|
||||
workspaceId: string;
|
||||
fieldMetadataId?: string;
|
||||
fieldMetadataUniversalIdentifier?: string;
|
||||
}): Promise<FileWithSignedUrlDto> {
|
||||
}): Promise<FileWithSignedUrlDTO> {
|
||||
if (!fieldMetadataId && !fieldMetadataUniversalIdentifier) {
|
||||
throw new FilesFieldException(
|
||||
'fieldMetadataId or fieldMetadataUniversalIdentifier must be provided',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user