enable secure invite links

This commit is contained in:
Stephen
2026-03-16 14:30:25 +00:00
parent 9a22a273b8
commit 8dabe8a793
4 changed files with 27 additions and 109 deletions

View File

@@ -36,8 +36,6 @@ import { OwnershipService } from './ownership.service';
import { PublicApiKeyService } from './public-api-key.service';
import { RoleService } from './role.service';
const TAMPER_PROOF_INVITE_LINKS_EXPERIMENT = '061_tamper_proof_invite_links';
@Service()
export class UserService {
constructor(
@@ -191,38 +189,19 @@ export class UserService {
const inviteLinksEmailOnly = this.globalConfig.userManagement.inviteLinksEmailOnly;
// Check if tamper-proof invite links feature flag is enabled for the owner
let useTamperProofLinks = false;
try {
const featureFlags = await this.postHog.getFeatureFlags({
id: owner.id,
createdAt: owner.createdAt,
});
useTamperProofLinks = featureFlags[TAMPER_PROOF_INVITE_LINKS_EXPERIMENT] === true;
} catch (error) {
// If feature flag check fails, fall back to old mechanism
this.logger.debug('Failed to check feature flags for tamper-proof invite links', { error });
}
return await Promise.all(
Object.entries(toInviteUsers).map(async ([email, id]) => {
let inviteAcceptUrl: string;
if (useTamperProofLinks) {
// Use JWT-based tamper-proof invite links when feature flag is enabled
const token = this.jwtService.sign(
{
inviterId: owner.id,
inviteeId: id,
},
{
expiresIn: '90d',
},
);
inviteAcceptUrl = `${domain}/signup?token=${token}`;
} else {
// Use legacy invite links when feature flag is disabled
inviteAcceptUrl = `${domain}/signup?inviterId=${owner.id}&inviteeId=${id}`;
}
// Always use JWT-based tamper-proof invite links
const token = this.jwtService.sign(
{
inviterId: owner.id,
inviteeId: id,
},
{
expiresIn: '90d',
},
);
const inviteAcceptUrl = `${domain}/signup?token=${token}`;
const invitedUser: UserRequest.InviteResponse = {
user: {
id,
@@ -486,18 +465,8 @@ export class UserService {
throw new BadRequestError('Instance owner not found');
}
let isTamperProofLinksEnabled = false;
try {
const featureFlags = await this.postHog.getFeatureFlags({
id: instanceOwner.id,
createdAt: instanceOwner.createdAt,
});
isTamperProofLinksEnabled = featureFlags[TAMPER_PROOF_INVITE_LINKS_EXPERIMENT] === true;
} catch (error) {
this.logger.debug('Failed to check feature flags for tamper-proof invite links', { error });
}
if (isTamperProofLinksEnabled && payload.token) {
// Always prefer token-based invites (tamper-proof), but support legacy format for backward compatibility
if (payload.token) {
return await this.processTokenBasedInvite(payload.token);
}

View File

@@ -63,8 +63,6 @@ export const EXECUTION_LOGIC_V2_EXPERIMENT = {
variant: 'variant',
};
export const TAMPER_PROOF_INVITE_LINKS = createExperiment('061_tamper_proof_invite_links');
export const CREDENTIALS_APP_SELECTION_EXPERIMENT = createExperiment(
'065_credentials_app_selection',
);
@@ -117,7 +115,6 @@ export const EXPERIMENTS_TO_TRACK = [
RESOURCE_CENTER_EXPERIMENT.name,
EXECUTION_LOGIC_V2_EXPERIMENT.name,
COLLECTION_OVERHAUL_EXPERIMENT.name,
TAMPER_PROOF_INVITE_LINKS.name,
CREDENTIALS_APP_SELECTION_EXPERIMENT.name,
SIDEBAR_EXPANDED_EXPERIMENT.name,
EMPTY_STATE_EXPERIMENT.name,

View File

@@ -15,8 +15,6 @@ import { useClipboard } from '@/app/composables/useClipboard';
import { useI18n } from '@n8n/i18n';
import { usePageRedirectionHelper } from '@/app/composables/usePageRedirectionHelper';
import { I18nT } from 'vue-i18n';
import { usePostHog } from '@/app/stores/posthog.store';
import { TAMPER_PROOF_INVITE_LINKS } from '@/app/constants/experiments';
import {
N8nButton,
@@ -39,7 +37,6 @@ const NAME_EMAIL_FORMAT_REGEX = /^.* <(.*)>$/;
const usersStore = useUsersStore();
const settingsStore = useSettingsStore();
const postHog = usePostHog();
const clipboard = useClipboard();
const { showMessage, showError } = useToast();
@@ -90,10 +87,6 @@ const isChatUsersEnabled = computed((): boolean => {
);
});
const isTamperProofInviteLinksEnabled = computed(() =>
postHog.isVariantEnabled(TAMPER_PROOF_INVITE_LINKS.name, TAMPER_PROOF_INVITE_LINKS.variant),
);
const invitedUsers = computed(() => {
if (!showInviteUrls.value) return [];
return showInviteUrls.value.map((invite) => ({ ...invite.user, isPendingUser: true }));
@@ -176,17 +169,13 @@ async function onSubmit() {
if (successfulUrlInvites.length) {
if (successfulUrlInvites.length === 1) {
if (isTamperProofInviteLinksEnabled.value) {
try {
const url = await usersStore.generateInviteLink({
id: successfulUrlInvites[0].user.id,
});
void clipboard.copy(url.link);
} catch (error) {
showError(error, i18n.baseText('settings.users.inviteLinkError'));
}
} else {
void clipboard.copy(successfulUrlInvites[0].user.inviteAcceptUrl);
try {
const url = await usersStore.generateInviteLink({
id: successfulUrlInvites[0].user.id,
});
void clipboard.copy(url.link);
} catch (error) {
showError(error, i18n.baseText('settings.users.inviteLinkError'));
}
}
@@ -265,19 +254,12 @@ async function onCopyInviteLink(user: IInviteResponse['user']) {
return;
}
if (isTamperProofInviteLinksEnabled.value) {
try {
const url = await usersStore.generateInviteLink({ id: user.id });
void clipboard.copy(url.link);
showCopyInviteLinkToast([]);
} catch (error) {
showError(error, i18n.baseText('settings.users.inviteLinkError'));
}
} else {
if (user.inviteAcceptUrl) {
void clipboard.copy(user.inviteAcceptUrl);
showCopyInviteLinkToast([]);
}
try {
const url = await usersStore.generateInviteLink({ id: user.id });
void clipboard.copy(url.link);
showCopyInviteLinkToast([]);
} catch (error) {
showError(error, i18n.baseText('settings.users.inviteLinkError'));
}
}
@@ -374,7 +356,7 @@ onMounted(() => {
<div v-if="showInviteUrls">
<N8nUsersList :users="invitedUsers">
<template #actions="{ user }">
<N8nTooltip v-if="isTamperProofInviteLinksEnabled">
<N8nTooltip>
<template #content>
{{ i18n.baseText('settings.users.actions.generateInviteLink') }}
</template>
@@ -386,19 +368,6 @@ onMounted(() => {
@click="onCopyInviteLink(user)"
></N8nIconButton>
</N8nTooltip>
<N8nTooltip v-else-if="user.inviteAcceptUrl">
<template #content>
{{ i18n.baseText('settings.users.inviteLink.copy') }}
</template>
<N8nIconButton
variant="subtle"
icon="link"
:aria-label="i18n.baseText('settings.users.inviteLink.copy')"
data-test-id="copy-invite-link-button"
:data-invite-link="user.inviteAcceptUrl"
@click="onCopyInviteLink(user)"
></N8nIconButton>
</N8nTooltip>
</template>
</N8nUsersList>
</div>

View File

@@ -33,8 +33,6 @@ import SettingsUsersTable from '../components/SettingsUsersTable.vue';
import { I18nT } from 'vue-i18n';
import { useUserRoleProvisioningStore } from '@/features/settings/sso/provisioning/composables/userRoleProvisioning.store';
import N8nAlert from '@n8n/design-system/components/N8nAlert/Alert.vue';
import { usePostHog } from '@/app/stores/posthog.store';
import { TAMPER_PROOF_INVITE_LINKS } from '@/app/constants/experiments';
import {
N8nActionBox,
N8nButton,
@@ -59,7 +57,6 @@ const ssoStore = useSSOStore();
const documentTitle = useDocumentTitle();
const pageRedirectionHelper = usePageRedirectionHelper();
const userRoleProvisioningStore = useUserRoleProvisioningStore();
const postHog = usePostHog();
const i18n = useI18n();
@@ -79,10 +76,6 @@ const isInstanceRoleProvisioningEnabled = computed(
() => userRoleProvisioningStore.provisioningConfig?.scopesProvisionInstanceRole || false,
);
const isTamperProofInviteLinksEnabled = computed(() =>
postHog.isVariantEnabled(TAMPER_PROOF_INVITE_LINKS.name, TAMPER_PROOF_INVITE_LINKS.variant),
);
const isSSOEnabled = computed(() => !!ssoStore.isSamlLoginEnabled || !!ssoStore.isOidcLoginEnabled);
onMounted(async () => {
@@ -101,21 +94,11 @@ const usersListActions = computed((): Array<UserAction<IUser>> => {
label: i18n.baseText('settings.users.actions.generateInviteLink'),
value: 'generateInviteLink',
guard: (user) =>
isTamperProofInviteLinksEnabled.value &&
hasPermission(['rbac'], { rbac: { scope: 'user:generateInviteLink' } }) &&
usersStore.usersLimitNotReached &&
user.id !== usersStore.currentUserId &&
!user.firstName,
},
{
label: i18n.baseText('settings.users.actions.copyInviteLink'),
value: 'copyInviteLink',
guard: (user) =>
!isTamperProofInviteLinksEnabled.value &&
usersStore.usersLimitNotReached &&
!user.firstName &&
!!user.inviteAcceptUrl,
},
{
label: i18n.baseText('settings.users.actions.reinvite'),
value: 'reinvite',