feat(app): allow claiming ownership of unclaimed app registrations (#22609)
## After <img width="653" height="703" alt="image" src="https://github.com/user-attachments/assets/ebe800da-b00b-4239-99a9-e157f7bfd7a0" /> <img width="634" height="711" alt="image" src="https://github.com/user-attachments/assets/00a048bf-36b1-489f-a080-1ed2d069e625" /> ## Context App registrations track their owner via `ownerWorkspaceId`. Curated / catalog / CLI apps are seeded **unclaimed** (`ownerWorkspaceId: null`). Until now there was no way to take ownership of an unclaimed app from the UI — the only ownership action was **Transfer ownership**, which requires the caller to already be the owner, so it can't act on a null-owner app. This PR adds a way to **claim** an unclaimed app registration, and makes the owner always visible on the detail page. ## Behaviour Admin panel → app registration detail → General tab: - The **Owner** row is now always shown — an **Unclaimed** tag when there's no owner workspace (previously the row was hidden). - Danger zone buttons are ownership-aware: - **Unclaimed** app → **Delete app** + **Claim ownership** (claims it for the current workspace). - **Owned** app → **Delete app** + **Transfer ownership** (unchanged). Transfer is hidden for unclaimed apps because transferring requires the caller to already own the registration. ## Changes **Backend** - New `claimOwnership` service method: looks the registration up globally, rejects it if it already has an owner, otherwise assigns `ownerWorkspaceId` to the caller's workspace. - New `claimApplicationRegistrationOwnership` mutation, guarded by `WorkspaceAuthGuard` + `SettingsPermissionGuard(APPLICATIONS)` (same guards as transfer). - New `ClaimApplicationRegistrationOwnershipInput` DTO (`applicationRegistrationId`). **Frontend** - **Claim ownership** button (shown only when the registration has no owner workspace); opens a confirmation modal and calls the new mutation. - **Transfer ownership** button now renders only for owned registrations. - The **Owner** row in the general info card is always displayed, with an `Unclaimed` tag when there is no owner. **Generated** - Regenerated the checked-in GraphQL artifacts (`twenty-front` metadata, `twenty-client-sdk` schema/types) against the live server so codegen output matches. ## Verification - `nx typecheck twenty-front` and `nx typecheck twenty-server` pass. - `oxlint` + `oxfmt` pass on all changed source files. - Codegen is idempotent — re-running the three `graphql:generate` configs + `generate-metadata-client` produces no diff. - Verified end-to-end on the running app against the seeded unclaimed `Twenty CLI` registration (Owner shows `Unclaimed`; Danger zone shows Delete + Claim ownership). https://claude.ai/code/session_01U7rbxhBSUQRWBbdP5TmAgZ
This commit is contained in:
+17
@@ -27,6 +27,7 @@ import { ApplicationRegistrationEntity } from 'src/engine/core-modules/applicati
|
||||
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
|
||||
import { ApplicationTarballService } from 'src/engine/core-modules/application/application-registration/application-tarball.service';
|
||||
import { ApplicationRegistrationStatsDTO } from 'src/engine/core-modules/application/application-registration/dtos/application-registration-stats.dto';
|
||||
import { ClaimApplicationRegistrationOwnershipInput } from 'src/engine/core-modules/application/application-registration/dtos/claim-application-registration-ownership.input';
|
||||
import { CreateApplicationRegistrationDTO } from 'src/engine/core-modules/application/application-registration/dtos/create-application-registration.dto';
|
||||
import { CreateApplicationRegistrationInput } from 'src/engine/core-modules/application/application-registration/dtos/create-application-registration.input';
|
||||
import { PublicApplicationRegistrationDTO } from 'src/engine/core-modules/application/application-registration/dtos/public-application-registration.dto';
|
||||
@@ -310,6 +311,22 @@ export class ApplicationRegistrationResolver {
|
||||
});
|
||||
}
|
||||
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.APPLICATIONS),
|
||||
)
|
||||
@Mutation(() => ApplicationRegistrationEntity)
|
||||
async claimApplicationRegistrationOwnership(
|
||||
@Args()
|
||||
{ applicationRegistrationId }: ClaimApplicationRegistrationOwnershipInput,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
): Promise<ApplicationRegistrationEntity> {
|
||||
return this.applicationRegistrationService.claimOwnership({
|
||||
applicationRegistrationId,
|
||||
claimingWorkspaceId: workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.APPLICATIONS),
|
||||
|
||||
+36
-1
@@ -6,7 +6,7 @@ import crypto from 'crypto';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { type Manifest } from 'twenty-shared/application';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { ILike, type FindOptionsWhere, type Repository } from 'typeorm';
|
||||
import { ILike, IsNull, type FindOptionsWhere, type Repository } from 'typeorm';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { ALL_OAUTH_SCOPES } from 'src/engine/core-modules/application/application-oauth/constants/oauth-scopes';
|
||||
@@ -615,6 +615,41 @@ export class ApplicationRegistrationService {
|
||||
};
|
||||
}
|
||||
|
||||
async claimOwnership(params: {
|
||||
applicationRegistrationId: string;
|
||||
claimingWorkspaceId: string;
|
||||
}): Promise<ApplicationRegistrationEntity> {
|
||||
const registration = await this.findOneByIdGlobal(
|
||||
params.applicationRegistrationId,
|
||||
);
|
||||
|
||||
// Only unclaimed registrations (no owner workspace) can be claimed.
|
||||
if (isDefined(registration.ownerWorkspaceId)) {
|
||||
throw new ApplicationRegistrationException(
|
||||
'Application registration is already owned by a workspace',
|
||||
ApplicationRegistrationExceptionCode.INVALID_INPUT,
|
||||
);
|
||||
}
|
||||
|
||||
// Claim atomically: only update while still unowned so concurrent
|
||||
// claimers can't overwrite each other (first-claimant-wins).
|
||||
const updateResult = await this.applicationRegistrationRepository.update(
|
||||
{ id: registration.id, ownerWorkspaceId: IsNull() },
|
||||
{ ownerWorkspaceId: params.claimingWorkspaceId },
|
||||
);
|
||||
|
||||
if (updateResult.affected === 0) {
|
||||
throw new ApplicationRegistrationException(
|
||||
'Application registration is already owned by a workspace',
|
||||
ApplicationRegistrationExceptionCode.INVALID_INPUT,
|
||||
);
|
||||
}
|
||||
|
||||
return this.applicationRegistrationRepository.findOneOrFail({
|
||||
where: { id: registration.id },
|
||||
});
|
||||
}
|
||||
|
||||
async transferOwnership(params: {
|
||||
applicationRegistrationId: string;
|
||||
targetWorkspaceSubdomain: string;
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { ArgsType, Field } from '@nestjs/graphql';
|
||||
|
||||
import { IsUUID } from 'class-validator';
|
||||
|
||||
@ArgsType()
|
||||
export class ClaimApplicationRegistrationOwnershipInput {
|
||||
@Field(() => String)
|
||||
@IsUUID()
|
||||
applicationRegistrationId: string;
|
||||
}
|
||||
Reference in New Issue
Block a user