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
@@ -3418,6 +3418,7 @@ type Mutation {
updateApplicationRegistrationVariable(input: UpdateApplicationRegistrationVariableInput!): ApplicationRegistrationVariable!
deleteApplicationRegistrationVariable(id: String!): Boolean!
uploadAppTarball(file: Upload!, universalIdentifier: String): ApplicationRegistration!
claimApplicationRegistrationOwnership(applicationRegistrationId: String!): ApplicationRegistration!
transferApplicationRegistrationOwnership(applicationRegistrationId: String!, targetWorkspaceSubdomain: String!): ApplicationRegistration!
updateWorkspaceMemberRole(workspaceMemberId: UUID!, roleId: UUID!): WorkspaceMember!
createOneRole(createRoleInput: CreateRoleInput!): Role!
@@ -2952,6 +2952,7 @@ export interface Mutation {
updateApplicationRegistrationVariable: ApplicationRegistrationVariable
deleteApplicationRegistrationVariable: Scalars['Boolean']
uploadAppTarball: ApplicationRegistration
claimApplicationRegistrationOwnership: ApplicationRegistration
transferApplicationRegistrationOwnership: ApplicationRegistration
updateWorkspaceMemberRole: WorkspaceMember
createOneRole: Role
@@ -6198,6 +6199,7 @@ export interface MutationGenqlSelection{
updateApplicationRegistrationVariable?: (ApplicationRegistrationVariableGenqlSelection & { __args: {input: UpdateApplicationRegistrationVariableInput} })
deleteApplicationRegistrationVariable?: { __args: {id: Scalars['String']} }
uploadAppTarball?: (ApplicationRegistrationGenqlSelection & { __args: {file: Scalars['Upload'], universalIdentifier?: (Scalars['String'] | null)} })
claimApplicationRegistrationOwnership?: (ApplicationRegistrationGenqlSelection & { __args: {applicationRegistrationId: Scalars['String']} })
transferApplicationRegistrationOwnership?: (ApplicationRegistrationGenqlSelection & { __args: {applicationRegistrationId: Scalars['String'], targetWorkspaceSubdomain: Scalars['String']} })
updateWorkspaceMemberRole?: (WorkspaceMemberGenqlSelection & { __args: {workspaceMemberId: Scalars['UUID'], roleId: Scalars['UUID']} })
createOneRole?: (RoleGenqlSelection & { __args: {createRoleInput: CreateRoleInput} })
@@ -8212,6 +8212,15 @@ export default {
]
}
],
"claimApplicationRegistrationOwnership": [
8,
{
"applicationRegistrationId": [
1,
"String!"
]
}
],
"transferApplicationRegistrationOwnership": [
8,
{
@@ -2531,6 +2531,7 @@ export type Mutation = {
checkCustomDomainValidRecords?: Maybe<DomainValidRecords>;
checkPublicDomainValidRecords?: Maybe<DomainValidRecords>;
checkoutSession: BillingSession;
claimApplicationRegistrationOwnership: ApplicationRegistration;
completeFileUpload: FileWithSignedUrl;
createApiKey: ApiKey;
createApplicationRegistration: CreateApplicationRegistration;
@@ -2808,6 +2809,11 @@ export type MutationCheckoutSessionArgs = {
};
export type MutationClaimApplicationRegistrationOwnershipArgs = {
applicationRegistrationId: Scalars['String']['input'];
};
export type MutationCompleteFileUploadArgs = {
fileId: Scalars['String']['input'];
};
@@ -7747,6 +7753,13 @@ export type MyMessageFoldersQuery = { __typename?: 'Query', myMessageFolders: Ar
export type ApplicationRegistrationFragmentFragment = { __typename?: 'ApplicationRegistration', id: string, universalIdentifier: string, name: string, logoUrl?: string | null, oAuthClientId: string, oAuthRedirectUris: Array<string>, oAuthScopes: Array<string>, sourceType: ApplicationRegistrationSourceType, sourcePackage?: string | null, latestAvailableVersion?: string | null, isListed: boolean, isFeatured: boolean, isPreInstalled: boolean, isConfigured: boolean, ownerWorkspaceId?: string | null, createdAt: string, updatedAt: string };
export type ClaimApplicationRegistrationOwnershipMutationVariables = Exact<{
applicationRegistrationId: Scalars['String']['input'];
}>;
export type ClaimApplicationRegistrationOwnershipMutation = { __typename?: 'Mutation', claimApplicationRegistrationOwnership: { __typename?: 'ApplicationRegistration', id: string, name: string } };
export type DeleteApplicationRegistrationMutationVariables = Exact<{
id: Scalars['String']['input'];
}>;
@@ -8970,6 +8983,7 @@ export const MyCalendarChannelsDocument = {"kind":"Document","definitions":[{"ki
export const MyConnectedAccountsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"MyConnectedAccounts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"myConnectedAccounts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"handle"}},{"kind":"Field","name":{"kind":"Name","value":"provider"}},{"kind":"Field","name":{"kind":"Name","value":"authFailedAt"}},{"kind":"Field","name":{"kind":"Name","value":"archivedAt"}},{"kind":"Field","name":{"kind":"Name","value":"scopes"}},{"kind":"Field","name":{"kind":"Name","value":"handleAliases"}},{"kind":"Field","name":{"kind":"Name","value":"lastSignedInAt"}},{"kind":"Field","name":{"kind":"Name","value":"userWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"connectionProviderId"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"lastCredentialsRefreshedAt"}},{"kind":"Field","name":{"kind":"Name","value":"connectionParameters"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"IMAP"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"host"}},{"kind":"Field","name":{"kind":"Name","value":"port"}},{"kind":"Field","name":{"kind":"Name","value":"connectionSecurity"}},{"kind":"Field","name":{"kind":"Name","value":"username"}}]}},{"kind":"Field","name":{"kind":"Name","value":"SMTP"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"host"}},{"kind":"Field","name":{"kind":"Name","value":"port"}},{"kind":"Field","name":{"kind":"Name","value":"connectionSecurity"}},{"kind":"Field","name":{"kind":"Name","value":"username"}}]}},{"kind":"Field","name":{"kind":"Name","value":"CALDAV"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"host"}},{"kind":"Field","name":{"kind":"Name","value":"username"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode<MyConnectedAccountsQuery, MyConnectedAccountsQueryVariables>;
export const MyMessageChannelsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"MyMessageChannels"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"connectedAccountId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"myMessageChannels"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"connectedAccountId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"connectedAccountId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"handle"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"isContactAutoCreationEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"contactAutoCreationPolicy"}},{"kind":"Field","name":{"kind":"Name","value":"messageFolderImportPolicy"}},{"kind":"Field","name":{"kind":"Name","value":"excludeNonProfessionalEmails"}},{"kind":"Field","name":{"kind":"Name","value":"excludeGroupEmails"}},{"kind":"Field","name":{"kind":"Name","value":"isSyncEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"syncStatus"}},{"kind":"Field","name":{"kind":"Name","value":"syncStage"}},{"kind":"Field","name":{"kind":"Name","value":"syncStageStartedAt"}},{"kind":"Field","name":{"kind":"Name","value":"connectedAccountId"}},{"kind":"Field","name":{"kind":"Name","value":"connectedAccount"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"handle"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode<MyMessageChannelsQuery, MyMessageChannelsQueryVariables>;
export const MyMessageFoldersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"MyMessageFolders"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"messageChannelId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"myMessageFolders"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"messageChannelId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"messageChannelId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"isSynced"}},{"kind":"Field","name":{"kind":"Name","value":"isSentFolder"}},{"kind":"Field","name":{"kind":"Name","value":"parentFolderId"}},{"kind":"Field","name":{"kind":"Name","value":"externalId"}},{"kind":"Field","name":{"kind":"Name","value":"pendingSyncAction"}},{"kind":"Field","name":{"kind":"Name","value":"messageChannelId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode<MyMessageFoldersQuery, MyMessageFoldersQueryVariables>;
export const ClaimApplicationRegistrationOwnershipDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ClaimApplicationRegistrationOwnership"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"applicationRegistrationId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"claimApplicationRegistrationOwnership"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"applicationRegistrationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"applicationRegistrationId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode<ClaimApplicationRegistrationOwnershipMutation, ClaimApplicationRegistrationOwnershipMutationVariables>;
export const DeleteApplicationRegistrationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteApplicationRegistration"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteApplicationRegistration"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}]}]}}]} as unknown as DocumentNode<DeleteApplicationRegistrationMutation, DeleteApplicationRegistrationMutationVariables>;
export const RotateApplicationRegistrationClientSecretDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RotateApplicationRegistrationClientSecret"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"rotateApplicationRegistrationClientSecret"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"clientSecret"}}]}}]}}]} as unknown as DocumentNode<RotateApplicationRegistrationClientSecretMutation, RotateApplicationRegistrationClientSecretMutationVariables>;
export const TransferApplicationRegistrationOwnershipDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"TransferApplicationRegistrationOwnership"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"applicationRegistrationId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"targetWorkspaceSubdomain"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"transferApplicationRegistrationOwnership"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"applicationRegistrationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"applicationRegistrationId"}}},{"kind":"Argument","name":{"kind":"Name","value":"targetWorkspaceSubdomain"},"value":{"kind":"Variable","name":{"kind":"Name","value":"targetWorkspaceSubdomain"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode<TransferApplicationRegistrationOwnershipMutation, TransferApplicationRegistrationOwnershipMutationVariables>;
@@ -0,0 +1,14 @@
import { gql } from '@apollo/client';
export const CLAIM_APPLICATION_REGISTRATION_OWNERSHIP = gql`
mutation ClaimApplicationRegistrationOwnership(
$applicationRegistrationId: String!
) {
claimApplicationRegistrationOwnership(
applicationRegistrationId: $applicationRegistrationId
) {
id
name
}
}
`;
@@ -8,13 +8,14 @@ import { Trans, useLingui } from '@lingui/react/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { useState } from 'react';
import { SettingsPath } from 'twenty-shared/types';
import { IconShare, IconTrash } from 'twenty-ui/icon';
import { IconShare, IconTrash, IconUserPlus } from 'twenty-ui/icon';
import { AppTooltip, TooltipDelay } from 'twenty-ui/surfaces';
import { H1Title, H1TitleFontColor, H2Title } from 'twenty-ui/typography';
import { Button } from 'twenty-ui/input';
import { Section, SectionAlignment, SectionFontColor } from 'twenty-ui/layout';
import {
type ApplicationRegistration,
ClaimApplicationRegistrationOwnershipDocument,
DeleteApplicationRegistrationDocument,
FindApplicationRegistrationStatsDocument,
FindManyApplicationRegistrationsDocument,
@@ -35,6 +36,9 @@ const DELETE_REGISTRATION_MODAL_ID = 'delete-application-registration-modal';
const TRANSFER_OWNERSHIP_MODAL_ID =
'transfer-application-registration-ownership-modal';
const CLAIM_OWNERSHIP_MODAL_ID =
'claim-application-registration-ownership-modal';
const DELETE_REGISTRATION_BUTTON_ID = 'delete-registration-button';
const StyledDangerButtonGroup = styled.div`
@@ -56,10 +60,13 @@ export const SettingsAdminApplicationRegistrationDangerZone = ({
const [isLoading, setIsLoading] = useState(false);
const [isTransferring, setIsTransferring] = useState(false);
const [isClaiming, setIsClaiming] = useState(false);
const [transferSubdomain, setTransferSubdomain] = useState('');
const applicationRegistrationId = registration.id;
const isUnclaimed = !isDefined(registration.ownerWorkspaceId);
const { data: statsData } = useQuery(
FindApplicationRegistrationStatsDocument,
{
@@ -87,6 +94,13 @@ export const SettingsAdminApplicationRegistrationDangerZone = ({
},
);
const [claimOwnership] = useMutation(
ClaimApplicationRegistrationOwnershipDocument,
{
refetchQueries: [FindManyApplicationRegistrationsDocument],
},
);
const handleDelete = async () => {
setIsLoading(true);
try {
@@ -153,6 +167,25 @@ export const SettingsAdminApplicationRegistrationDangerZone = ({
}
};
const handleClaimOwnership = async () => {
setIsClaiming(true);
try {
await claimOwnership({
variables: { applicationRegistrationId },
});
enqueueSuccessSnackBar({
message: t`Ownership claimed successfully`,
});
closeModal(CLAIM_OWNERSHIP_MODAL_ID);
} catch {
enqueueErrorSnackBar({
message: t`Failed to claim ownership.`,
});
} finally {
setIsClaiming(false);
}
};
const confirmationValue = t`yes`;
return (
@@ -182,13 +215,23 @@ export const SettingsAdminApplicationRegistrationDangerZone = ({
delay={TooltipDelay.shortDelay}
/>
)}
<Button
accent="default"
variant="secondary"
title={t`Transfer ownership`}
Icon={IconShare}
onClick={() => openModal(TRANSFER_OWNERSHIP_MODAL_ID)}
/>
{isUnclaimed ? (
<Button
accent="default"
variant="secondary"
title={t`Claim ownership`}
Icon={IconUserPlus}
onClick={() => openModal(CLAIM_OWNERSHIP_MODAL_ID)}
/>
) : (
<Button
accent="default"
variant="secondary"
title={t`Transfer ownership`}
Icon={IconShare}
onClick={() => openModal(TRANSFER_OWNERSHIP_MODAL_ID)}
/>
)}
</StyledDangerButtonGroup>
</Section>
@@ -209,6 +252,21 @@ export const SettingsAdminApplicationRegistrationDangerZone = ({
loading={isLoading}
/>
<ConfirmationModal
modalInstanceId={CLAIM_OWNERSHIP_MODAL_ID}
title={t`Claim ownership`}
subtitle={
<Trans>
This app has no owner workspace. Claiming it will assign ownership
to your current workspace, and you will be able to manage it.
</Trans>
}
onConfirmClick={handleClaimOwnership}
confirmButtonText={t`Claim`}
confirmButtonAccent="blue"
loading={isClaiming}
/>
<StyledAppModal
modalId={TRANSFER_OWNERSHIP_MODAL_ID}
isClosable
@@ -108,28 +108,26 @@ export const SettingsApplicationRegistrationGeneralInfo = ({
},
];
if (isDefined(ownerWorkspace?.displayName)) {
items.push({
Icon: IconTag,
label: t`Owner`,
value: (
<Chip
size={ChipSize.Large}
variant={ChipVariant.Highlighted}
clickable={false}
leftComponent={
<AvatarOrIcon
avatarType="rounded"
avatarUrl={getAbsoluteImageUrl(
ownerWorkspace?.logo ?? undefined,
)}
/>
}
label={ownerWorkspace.displayName}
/>
),
});
}
items.push({
Icon: IconTag,
label: t`Owner`,
value: isDefined(ownerWorkspace?.displayName) ? (
<Chip
size={ChipSize.Large}
variant={ChipVariant.Highlighted}
clickable={false}
leftComponent={
<AvatarOrIcon
avatarType="rounded"
avatarUrl={getAbsoluteImageUrl(ownerWorkspace?.logo ?? undefined)}
/>
}
label={ownerWorkspace.displayName}
/>
) : (
<Tag color="orange" text={t`Unclaimed`} />
),
});
switch (registration.sourceType) {
case ApplicationRegistrationSourceType.NPM:
@@ -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;
}