enable secure invite links
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user