feat(editor): Explain a restricted model provider with the restriction popover (#40705)

This commit is contained in:
Theodore Nastos
2026-10-09 13:52:20 +00:00
committed by GitHub
parent ee8a608a22
commit 75de193d90
13 changed files with 364 additions and 51 deletions
@@ -168,6 +168,29 @@ describe('N8nAiModelSelectorDropdown', () => {
expect(getByTestId('ai-model-selector-restricted-icon')).toBeInTheDocument();
});
it('renders the item-restricted slot in place of the lock', async () => {
const { getByTestId, queryByTestId } = render(N8nAiModelSelectorDropdown, {
props: {
...defaultProps,
items: [
...baseItems,
{
id: 'anthropic',
label: 'Anthropic',
disabled: true,
data: { restrictedLabel: 'Restricted on this instance' },
},
],
},
slots: { 'item-restricted': '<span data-test-id="custom-restricted-marker" />' },
});
await userEvent.click(getByTestId('ai-model-selector'));
await waitFor(() => expect(getByTestId('custom-restricted-marker')).toBeInTheDocument());
expect(queryByTestId('ai-model-selector-restricted-icon')).not.toBeInTheDocument();
});
it('emits select and search events', async () => {
const { getByTestId, getByText, emitted } = render(N8nAiModelSelectorDropdown, {
props: {
@@ -79,6 +79,7 @@ const emit = defineEmits<{
defineSlots<{
'trigger-leading'?: (props: { ui: { class: string } }) => void;
'item-leading'?: (props: { item: AiModelSelectorMenuItem<TData>; ui: { class: string } }) => void;
'item-restricted'?: (props: { item: AiModelSelectorMenuItem<TData> }) => void;
}>();
const dropdownRef = useTemplateRef('dropdownRef');
@@ -106,6 +107,7 @@ defineExpose({
open: () => {
if (!disabled) dropdownRef.value?.open();
},
close: () => dropdownRef.value?.close(),
});
</script>
@@ -233,12 +235,14 @@ defineExpose({
<N8nText size="xsmall" color="text-light" data-test-id="ai-model-selector-restriction">
{{ item.data.restrictedLabel }}
</N8nText>
<N8nIcon
icon="lock"
size="xsmall"
color="text-light"
data-test-id="ai-model-selector-restricted-icon"
/>
<slot name="item-restricted" :item="item">
<N8nIcon
icon="lock"
size="xsmall"
color="text-light"
data-test-id="ai-model-selector-restricted-icon"
/>
</slot>
</span>
<N8nTooltip
v-else-if="item.data?.description"
@@ -2542,9 +2542,13 @@
"typeAvailabilityPolicies.restrictedNode.agentTool.instance": "{nodeType} is restricted on this instance. The agent can't use it.",
"typeAvailabilityPolicies.restrictedNode.agentTool.project": "{nodeType} is restricted in this project. The agent can't use it.",
"typeAvailabilityPolicies.restrictedNode.agentTool.generic": "{nodeType} is restricted. The agent can't use it.",
"typeAvailabilityPolicies.restrictedCredentialType.title": "This credential type is restricted",
"typeAvailabilityPolicies.restrictedCredentialType.popover.description": "An administrator blocked this credential type. Workflows can't use credentials of this type until an instance administrator grants access.",
"typeAvailabilityPolicies.contactAdmin.title": "Instance admins",
"typeAvailabilityPolicies.contactAdmin.description": "Instance admins manage which nodes are available. Contact one of them to request access to '{nodeType}'.",
"typeAvailabilityPolicies.contactAdmin.mailSubject": "Access request for the {nodeType} node",
"typeAvailabilityPolicies.contactAdmin.credentialType.description": "Instance admins manage which credential types are available. Contact one of them to request access to {nodeType}.",
"typeAvailabilityPolicies.contactAdmin.credentialType.mailSubject": "Access request for {nodeType}",
"typeAvailabilityPolicies.contactAdmin.empty": "No instance admin is listed here. Ask the person who manages this n8n instance.",
"typeAvailabilityPolicies.violations.nodeType": "{name} node",
"typeAvailabilityPolicies.violations.credentialType": "{name} credential",
@@ -5761,6 +5765,7 @@
"agents.modelSelector.models": "Models",
"agents.modelSelector.credentialsMissing": "Credentials missing",
"agents.modelSelector.restricted": "Restricted",
"agents.modelSelector.restrictedCredentials": "{provider} credentials",
"agents.modelSelector.freeCredits.label": "Use free OpenAI credits",
"agents.modelSelector.freeCredits.badge": "free credits",
"agents.modelSelector.freeCredits.description": "Get {credits} free OpenAI API credits. Try it with gpt-5-mini.",
@@ -51,6 +51,7 @@ const restrictedCredentialTypes = vi.hoisted(() => ({
value: {} as Record<string, 'instance' | 'project'>,
}));
const openNewCredential = vi.hoisted(() => vi.fn());
const closeMenu = vi.hoisted(() => vi.fn());
const openModalWithData = vi.hoisted(() => vi.fn());
const aiGatewayState = vi.hoisted(() => ({
isEnabled: { value: false },
@@ -85,6 +86,7 @@ const baseText = vi.hoisted(() =>
'Get {credits} free OpenAI API credits. Try it with gpt-5-mini.',
'generic.loadingEllipsis': 'Loading...',
'agents.modelSelector.restricted': 'Restricted',
'agents.modelSelector.restrictedCredentials': '{provider} credentials',
'typeAvailabilityPolicies.restrictedNode.scope.instance': 'Restricted on this instance',
'typeAvailabilityPolicies.restrictedNode.scope.project': 'Restricted in this project',
}[key] ?? key;
@@ -110,6 +112,22 @@ vi.mock('@n8n/frontend-module-type-availability-policies', async (importOriginal
return scope ? { name, available: false, scope } : { name, available: true };
},
}),
ContactInstanceAdminModal: {
name: 'ContactInstanceAdminModal',
props: ['open', 'nodeTypeName', 'kind'],
template:
'<div v-if="open" data-testid="contact-instance-admin-modal">{{ nodeTypeName }}</div>',
},
}));
vi.mock('../components/model-selector/ModelSelectorRestrictedMarker.vue', () => ({
default: {
name: 'ModelSelectorRestrictedMarker',
props: ['name', 'scope'],
emits: ['contactAdmin'],
template:
'<button data-testid="restricted-marker" @click="$emit(\'contactAdmin\')">{{ name }}</button>',
},
}));
vi.mock('@n8n/permissions', async (importOriginal) => ({
@@ -134,7 +152,12 @@ vi.mock('@n8n/design-system', async () => {
'dataTestId',
'credentialDataTestId',
],
template: '<div data-testid="ai-model-selector-dropdown" />',
methods: { open: vi.fn(), close: closeMenu },
template: `<div data-testid="ai-model-selector-dropdown">
<template v-for="item in items" :key="item.id">
<slot v-if="item.data?.restrictedLabel" name="item-restricted" :item="item" />
</template>
</div>`,
},
N8nIcon: { template: '<span />', props: ['icon', 'size'] },
useDropdownSearch: () => ({
@@ -285,6 +308,7 @@ describe('AgentModelSelector', () => {
freeAiCreditsState.claimCreditsAndGetCredential.mockReset();
canCreateCredentials.value = true;
restrictedCredentialTypes.value = {};
closeMenu.mockReset();
aiGatewayState.isEnabled.value = false;
aiGatewayState.supportedTypes = new Set<string>();
aiGatewayState.balance.value = undefined;
@@ -380,6 +404,25 @@ describe('AgentModelSelector', () => {
);
});
it('explains a restricted provider on its row and opens the contact-admin dialog from it', async () => {
restrictedCredentialTypes.value = { openAiApi: 'project' };
const wrapper = await mountSelector({ anthropic: null });
const marker = wrapper.findComponent({ name: 'ModelSelectorRestrictedMarker' });
expect(marker.props()).toEqual({ name: 'OpenAI credentials', scope: 'project' });
expect(wrapper.findComponent({ name: 'ContactInstanceAdminModal' }).props('open')).toBe(false);
await marker.find('button').trigger('click');
expect(closeMenu).toHaveBeenCalledOnce();
expect(wrapper.findComponent({ name: 'ContactInstanceAdminModal' }).props()).toEqual({
open: true,
kind: 'credential',
nodeTypeName: 'OpenAI credentials',
});
});
it('keeps a multi-credential-type provider usable while one type is still allowed', async () => {
restrictedCredentialTypes.value = { azureOpenAiApi: 'instance' };
@@ -0,0 +1,47 @@
import { mount } from '@vue/test-utils';
import { defineComponent, h, nextTick } from 'vue';
import { describe, expect, it, vi } from 'vitest';
import ModelSelectorRestrictedMarker from '../components/model-selector/ModelSelectorRestrictedMarker.vue';
vi.mock('@n8n/frontend-module-type-availability-policies', () => ({
RestrictedNodePopover: {
name: 'RestrictedNodePopover',
props: ['nodeTypeName', 'scope', 'kind', 'anchor', 'side', 'markerSize'],
emits: ['contactAdmin'],
template: '<button @click="$emit(\'contactAdmin\')" />',
},
}));
describe('ModelSelectorRestrictedMarker', () => {
it('anchors the popover to its menu row and forwards the contact-admin request', async () => {
const onContactAdmin = vi.fn();
const Row = defineComponent({
render: () =>
h('div', { role: 'menuitem', 'data-test-id': 'row' }, [
h(ModelSelectorRestrictedMarker, {
name: 'Anthropic credentials',
scope: 'instance',
onContactAdmin,
}),
]),
});
const wrapper = mount(Row, { attachTo: document.body });
await nextTick();
const popover = wrapper.findComponent({ name: 'RestrictedNodePopover' });
expect(popover.props()).toMatchObject({
kind: 'credential',
side: 'right',
markerSize: 'xsmall',
nodeTypeName: 'Anthropic credentials',
scope: 'instance',
anchor: wrapper.find('[data-test-id="row"]').element,
});
await popover.find('button').trigger('click');
expect(onContactAdmin).toHaveBeenCalledOnce();
wrapper.unmount();
});
});
@@ -16,11 +16,13 @@ import { useFreeAiCredits } from '@/app/composables/useFreeAiCredits';
import { useAiGateway } from '@/app/composables/useAiGateway';
import { AI_GATEWAY_MANAGED_TAG, type CredentialTypeAvailability } from '@n8n/api-types';
import {
ContactInstanceAdminModal,
SCOPE_LABEL_KEY,
useTypeAvailabilityPoliciesStore,
} from '@n8n/frontend-module-type-availability-policies';
import ModelSelectorTriggerIcon from './model-selector/ModelSelectorTriggerIcon.vue';
import ModelSelectorItemLeadingIcon from './model-selector/ModelSelectorItemLeadingIcon.vue';
import ModelSelectorRestrictedMarker from './model-selector/ModelSelectorRestrictedMarker.vue';
import { buildMenuItemId, parseMenuItemId } from './model-selector/menuItemId';
import { useModelCatalog } from '../composables/useModelCatalog';
import {
@@ -42,6 +44,7 @@ const FREE_OPENAI_CREDITS_MODEL = 'gpt-5-mini';
type MenuItemData = AiModelSelectorMenuItemData & {
provider?: AgentModelProvider;
restriction?: CredentialTypeAvailability;
};
type MenuItem = AiModelSelectorMenuItem<MenuItemData>;
@@ -98,6 +101,7 @@ const pendingDefaultCredential = ref<{
provider: AgentModelProvider;
credentialId: string;
} | null>(null);
const contactAdminSubject = ref<string | null>(null);
const forceModelOptionsDisabled = ref(false);
const isResolvingDefaultModel = computed(
() => pendingDefaultCredential.value !== null || forceModelOptionsDisabled.value,
@@ -232,6 +236,17 @@ const restrictedLabel = computed(() =>
: undefined,
);
function restrictedCredentialsName(item: MenuItem): string {
return i18n.baseText('agents.modelSelector.restrictedCredentials', {
interpolate: { provider: item.label },
});
}
function requestContactAdmin(subject: string) {
dropdownRef.value?.close();
contactAdminSubject.value = subject;
}
function providerToMenuItem(provider: AgentModelProvider): MenuItem {
const definition = AGENT_MODEL_PROVIDER_DEFINITIONS[provider];
const credentialTypes = getProviderCredentialTypes(provider);
@@ -245,6 +260,7 @@ function providerToMenuItem(provider: AgentModelProvider): MenuItem {
provider,
credentialType: credentialTypes[0],
restrictedLabel: i18n.baseText(SCOPE_LABEL_KEY[restriction.scope ?? 'instance']),
restriction,
},
};
}
@@ -690,30 +706,53 @@ defineExpose({
</script>
<template>
<N8nAiModelSelectorDropdown
ref="dropdownRef"
:items="filteredMenu"
:is-loading="isLoading || isResolvingDefaultModel"
:selected-label="selectedLabel"
:selected-credential-name="selectedCredentialName"
:credentials-missing="isCredentialsMissing"
:restricted-label="restrictedLabel"
:no-match-label="i18n.baseText('agents.modelSelector.noMatch')"
:disabled="disabled"
data-test-id="agent-model-selector"
credential-data-test-id="agent-model-selector-credential"
@search="handleSearch"
@select="onSelect"
>
<template #trigger-leading="{ ui }">
<ModelSelectorTriggerIcon
:credential-type-name="triggerCredentialTypeName"
:class="ui.class"
/>
</template>
<div :class="$style.root">
<N8nAiModelSelectorDropdown
ref="dropdownRef"
:items="filteredMenu"
:is-loading="isLoading || isResolvingDefaultModel"
:selected-label="selectedLabel"
:selected-credential-name="selectedCredentialName"
:credentials-missing="isCredentialsMissing"
:restricted-label="restrictedLabel"
:no-match-label="i18n.baseText('agents.modelSelector.noMatch')"
:disabled="disabled"
data-test-id="agent-model-selector"
credential-data-test-id="agent-model-selector-credential"
@search="handleSearch"
@select="onSelect"
>
<template #trigger-leading="{ ui }">
<ModelSelectorTriggerIcon
:credential-type-name="triggerCredentialTypeName"
:class="ui.class"
/>
</template>
<template #item-leading="{ item, ui }">
<ModelSelectorItemLeadingIcon :item="item" :class="ui.class" />
</template>
</N8nAiModelSelectorDropdown>
<template #item-leading="{ item, ui }">
<ModelSelectorItemLeadingIcon :item="item" :class="ui.class" />
</template>
<template #item-restricted="{ item }">
<ModelSelectorRestrictedMarker
:name="restrictedCredentialsName(item)"
:scope="item.data?.restriction?.scope"
@contact-admin="requestContactAdmin(restrictedCredentialsName(item))"
/>
</template>
</N8nAiModelSelectorDropdown>
<ContactInstanceAdminModal
kind="credential"
:node-type-name="contactAdminSubject ?? ''"
:open="contactAdminSubject !== null"
@update:open="(isOpen) => !isOpen && (contactAdminSubject = null)"
/>
</div>
</template>
<style lang="scss" module>
// The dialog is a sibling of the dropdown; the wrapper must not change the host layout.
.root {
display: contents;
}
</style>
@@ -0,0 +1,36 @@
<script setup lang="ts">
import type { CredentialTypeAvailabilityScope } from '@n8n/api-types';
import { RestrictedNodePopover } from '@n8n/frontend-module-type-availability-policies';
import { computed, useTemplateRef } from 'vue';
defineProps<{
name: string;
scope?: CredentialTypeAvailabilityScope;
}>();
const emit = defineEmits<{ contactAdmin: [] }>();
const rootRef = useTemplateRef<HTMLElement>('rootRef');
// The popover opens on hover of the whole menu row, which the dropdown slot does not hand over.
const row = computed(() => rootRef.value?.closest<HTMLElement>('[role="menuitem"]') ?? undefined);
</script>
<template>
<span ref="rootRef" :class="$style.root">
<RestrictedNodePopover
kind="credential"
side="right"
marker-size="xsmall"
:node-type-name="name"
:scope="scope"
:anchor="row"
@contact-admin="emit('contactAdmin')"
/>
</span>
</template>
<style lang="scss" module>
.root {
display: contents;
}
</style>
@@ -61,6 +61,21 @@ describe('ContactInstanceAdminModal', () => {
expect(getByText(/request access to 'Slack'/)).toBeInTheDocument();
});
it('builds the credential type copy', async () => {
setup([owner]);
const { findByTestId, getByText } = renderComponent({
props: { open: true, nodeTypeName: 'Anthropic credentials', kind: 'credential' },
});
await findByTestId('contact-instance-admin-list');
expect(getByText('[email protected]').closest('a')).toHaveAttribute(
'href',
'mailto:[email protected]?subject=Access%20request%20for%20Anthropic%20credentials',
);
expect(getByText(/request access to Anthropic credentials/)).toBeInTheDocument();
});
it('keeps loading until the latest owner lookup resolves after a reopen', async () => {
const lookups: Array<() => void> = [];
setup(
@@ -5,8 +5,14 @@ import { useI18n } from '@n8n/i18n';
import { useUsersStore } from '@n8n/stores/users.store';
import { computed, ref, watch } from 'vue';
const { nodeTypeName } = defineProps<{
import {
RESTRICTED_TYPE_COPY,
type RestrictedTypeKind,
} from '../type-availability-policies.constants';
const { nodeTypeName, kind = 'node' } = defineProps<{
nodeTypeName: string;
kind?: RestrictedTypeKind;
}>();
const open = defineModel<boolean>('open', { default: false });
@@ -18,12 +24,12 @@ const isLoading = ref(false);
let lookupId = 0;
const description = computed(() =>
i18n.baseText('typeAvailabilityPolicies.contactAdmin.description', {
i18n.baseText(RESTRICTED_TYPE_COPY[kind].contactDescription, {
interpolate: { nodeType: nodeTypeName },
}),
);
const mailSubject = computed(() =>
i18n.baseText('typeAvailabilityPolicies.contactAdmin.mailSubject', {
i18n.baseText(RESTRICTED_TYPE_COPY[kind].mailSubject, {
interpolate: { nodeType: nodeTypeName },
}),
);
@@ -1,7 +1,7 @@
import { createComponentRenderer } from '@n8n/frontend-test-utils';
import { screen, waitFor } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, it, expect } from 'vitest';
import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest';
import RestrictedNodePopover from './RestrictedNodePopover.vue';
@@ -194,4 +194,39 @@ describe('RestrictedNodePopover', () => {
expect(screen.getByTestId('contact-instance-admin-modal')).toHaveTextContent('Gmail');
});
it('uses the credential type copy', async () => {
renderPopover({ active: true, kind: 'credential', scope: undefined });
expect(await screen.findByText('This credential type is restricted')).toBeInTheDocument();
expect(
screen.getByText(
"An administrator blocked this credential type. Workflows can't use credentials of this type until an instance administrator grants access.",
),
).toBeInTheDocument();
});
it('hands the contact-admin request to a listening parent instead of opening its own dialog', async () => {
const onContactAdmin = vi.fn();
renderComponent({ props: { active: true }, attrs: { onContactAdmin } });
await userEvent.click(await screen.findByTestId('node-restricted-contact-admin'));
expect(onContactAdmin).toHaveBeenCalledOnce();
expect(screen.queryByTestId('contact-instance-admin-modal')).not.toBeInTheDocument();
});
it('keeps a pointer-down inside the popover from reaching the document', async () => {
const onDocumentPointerDown = vi.fn();
document.addEventListener('pointerdown', onDocumentPointerDown);
renderPopover({ active: true });
await userEvent.pointer({
keys: '[MouseLeft>]',
target: await screen.findByTestId('node-restricted-contact-admin'),
});
expect(onDocumentPointerDown).not.toHaveBeenCalled();
document.removeEventListener('pointerdown', onDocumentPointerDown);
});
});
@@ -3,16 +3,21 @@ import type { NodeTypeAvailabilityScope } from '@n8n/api-types';
import { N8nButton, N8nIcon, N8nPopover, N8nText } from '@n8n/design-system';
import { useI18n, type BaseTextKey } from '@n8n/i18n';
import { unrefElement, useActiveElement, useElementHover, type MaybeElement } from '@vueuse/core';
import { computed, ref, watch } from 'vue';
import { computed, getCurrentInstance, ref, watch } from 'vue';
import { useExclusiveOpen } from '../composables/useExclusiveOpen';
import { SCOPE_LABEL_KEY } from '../type-availability-policies.constants';
import {
RESTRICTED_TYPE_COPY,
SCOPE_LABEL_KEY,
type RestrictedTypeKind,
} from '../type-availability-policies.constants';
import ContactInstanceAdminModal from './ContactInstanceAdminModal.vue';
const props = withDefaults(
defineProps<{
nodeTypeName: string;
scope?: NodeTypeAvailabilityScope;
kind?: RestrictedTypeKind;
/** The list row the popover explains. It opens beside this element, not beside the lock. */
anchor?: MaybeElement;
/** Keyboard-active without DOM focus, such as a virtual list selection. */
@@ -20,10 +25,24 @@ const props = withDefaults(
side?: 'top' | 'right' | 'bottom' | 'left';
align?: 'start' | 'center' | 'end';
sideOffset?: number;
markerSize?: 'small' | 'xsmall';
}>(),
{ scope: undefined, anchor: undefined, side: 'left', align: 'center', sideOffset: 24 },
{
kind: 'node',
scope: undefined,
anchor: undefined,
active: false,
side: 'left',
align: 'center',
sideOffset: 24,
markerSize: 'small',
},
);
/** A parent that listens owns the contact-admin dialog, so it can live outside a menu that closes. */
const emit = defineEmits<{ contactAdmin: [] }>();
const parentHandlesContactAdmin = Boolean(getCurrentInstance()?.vnode.props?.onContactAdmin);
/** Leaving waits this long before closing, so the pointer can cross the gap to the popover. */
const HOVER_GRACE_MS = 200;
@@ -55,11 +74,15 @@ const wantsOpen = computed(
);
const open = useExclusiveOpen(wantsOpen);
const copy = computed(() => RESTRICTED_TYPE_COPY[props.kind]);
const scopeKey = computed<BaseTextKey>(
() =>
(props.scope && SCOPE_LABEL_KEY[props.scope]) ??
'typeAvailabilityPolicies.restrictedNode.title',
() => (props.scope && SCOPE_LABEL_KEY[props.scope]) ?? copy.value.title,
);
function requestContactAdmin() {
if (parentHandlesContactAdmin) emit('contactAdmin');
else isContactAdminOpen.value = true;
}
</script>
<template>
@@ -67,9 +90,9 @@ const scopeKey = computed<BaseTextKey>(
<!-- The tool pickers render this inside a modal. -->
<N8nPopover
:open="open"
:side="props.side"
:align="props.align"
:side-offset="props.sideOffset"
:side="side"
:align="align"
:side-offset="sideOffset"
:reference="anchorElement"
:suppress-auto-focus="true"
:content-class="$style.card"
@@ -83,22 +106,28 @@ const scopeKey = computed<BaseTextKey>(
role="img"
:aria-label="i18n.baseText('typeAvailabilityPolicies.restrictedNode.title')"
>
<N8nIcon icon="lock" size="small" data-test-id="node-restricted-icon" />
<N8nIcon icon="lock" :size="markerSize" data-test-id="node-restricted-icon" />
</span>
</template>
<template #content>
<div ref="contentRef" :class="$style.popover" data-test-id="node-restricted-popover">
<!-- A menu around the anchor dismisses on any pointer-down it sees outside itself; keep ours from reaching it. -->
<div
ref="contentRef"
:class="$style.popover"
data-test-id="node-restricted-popover"
@pointerdown.stop
>
<N8nText tag="p" size="large" color="text-dark">{{ nodeTypeName }}</N8nText>
<N8nText tag="p" size="small" color="text-light">{{ i18n.baseText(scopeKey) }}</N8nText>
<N8nText tag="p" size="small" color="text-base" :class="$style.description">
{{ i18n.baseText('typeAvailabilityPolicies.restrictedNode.popover.description') }}
{{ i18n.baseText(copy.popoverDescription) }}
</N8nText>
<N8nButton
variant="outline"
size="small"
:class="$style.action"
data-test-id="node-restricted-contact-admin"
@click="isContactAdminOpen = true"
@click="requestContactAdmin"
>
{{ i18n.baseText('typeAvailabilityPolicies.restrictedNode.contactAdmin') }}
<N8nIcon icon="arrow-up-right" size="xsmall" />
@@ -107,7 +136,12 @@ const scopeKey = computed<BaseTextKey>(
</template>
</N8nPopover>
<!-- A sibling of the popover: its content unmounts on close and must not take the dialog with it. -->
<ContactInstanceAdminModal v-model:open="isContactAdminOpen" :node-type-name="nodeTypeName" />
<ContactInstanceAdminModal
v-if="!parentHandlesContactAdmin"
v-model:open="isContactAdminOpen"
:node-type-name="nodeTypeName"
:kind="kind"
/>
</span>
</template>
@@ -1,6 +1,6 @@
export { TypeAvailabilityPoliciesModule } from './type-availability-policies.module';
export { useTypeAvailabilityPoliciesStore } from './type-availability-policies.store';
export { SCOPE_LABEL_KEY } from './type-availability-policies.constants';
export { SCOPE_LABEL_KEY, type RestrictedTypeKind } from './type-availability-policies.constants';
export {
describeNodeTypeRestriction,
getNodeTypeRestriction,
@@ -11,5 +11,6 @@ export {
export { default as RestrictedNodePopover } from './components/RestrictedNodePopover.vue';
export { default as RestrictedNodePanel } from './components/RestrictedNodePanel.vue';
export { default as RestrictedToolCallout } from './components/RestrictedToolCallout.vue';
export { default as ContactInstanceAdminModal } from './components/ContactInstanceAdminModal.vue';
export { getPolicyViolations } from './policy-violations/policyViolations';
export { default as PolicyViolationList } from './policy-violations/PolicyViolationList.vue';
@@ -14,3 +14,28 @@ export const SCOPE_LABEL_KEY: Record<NodeTypeAvailabilityScope, BaseTextKey> = {
export function isRestrictionScope(scope: string): scope is NodeTypeAvailabilityScope {
return Object.hasOwn(SCOPE_LABEL_KEY, scope);
}
export type RestrictedTypeKind = 'node' | 'credential';
export const RESTRICTED_TYPE_COPY: Record<
RestrictedTypeKind,
{
title: BaseTextKey;
popoverDescription: BaseTextKey;
contactDescription: BaseTextKey;
mailSubject: BaseTextKey;
}
> = {
node: {
title: 'typeAvailabilityPolicies.restrictedNode.title',
popoverDescription: 'typeAvailabilityPolicies.restrictedNode.popover.description',
contactDescription: 'typeAvailabilityPolicies.contactAdmin.description',
mailSubject: 'typeAvailabilityPolicies.contactAdmin.mailSubject',
},
credential: {
title: 'typeAvailabilityPolicies.restrictedCredentialType.title',
popoverDescription: 'typeAvailabilityPolicies.restrictedCredentialType.popover.description',
contactDescription: 'typeAvailabilityPolicies.contactAdmin.credentialType.description',
mailSubject: 'typeAvailabilityPolicies.contactAdmin.credentialType.mailSubject',
},
};