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:
@@ -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>;
|
||||
|
||||
+14
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
+66
-8
@@ -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
|
||||
|
||||
+20
-22
@@ -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:
|
||||
|
||||
+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