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:
martmull
2026-07-07 11:07:21 +02:00
committed by GitHub
parent 5dc9d7ab36
commit 2a495c3477
10 changed files with 189 additions and 31 deletions
@@ -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),
@@ -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;
@@ -0,0 +1,10 @@
import { ArgsType, Field } from '@nestjs/graphql';
import { IsUUID } from 'class-validator';
@ArgsType()
export class ClaimApplicationRegistrationOwnershipInput {
@Field(() => String)
@IsUUID()
applicationRegistrationId: string;
}