fix(editor): Follow up fixes and improvements to viewer role (#10684)

This commit is contained in:
Raúl Gómez Morales 2024-09-10 11:06:05 +02:00 committed by GitHub
parent efa5573278
commit 63548e6ead
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 109 additions and 34 deletions

View file

@ -85,4 +85,10 @@ describe('CredentialCard', () => {
} }
expect(actions).toHaveTextContent('Move'); expect(actions).toHaveTextContent('Move');
}); });
it('should set readOnly variant based on prop', () => {
const { getByRole } = renderComponent({ props: { readOnly: true } });
const heading = getByRole('heading');
expect(heading).toHaveTextContent('Read only');
});
}); });

View file

@ -138,16 +138,19 @@ function moveResource() {
<template #header> <template #header>
<n8n-heading tag="h2" bold :class="$style.cardHeading"> <n8n-heading tag="h2" bold :class="$style.cardHeading">
{{ data.name }} {{ data.name }}
<N8nBadge v-if="readOnly" class="ml-3xs" theme="tertiary" bold>
{{ locale.baseText('credentials.item.readonly') }}
</N8nBadge>
</n8n-heading> </n8n-heading>
</template> </template>
<div :class="$style.cardDescription"> <div :class="$style.cardDescription">
<n8n-text color="text-light" size="small"> <n8n-text color="text-light" size="small">
<span v-if="credentialType">{{ credentialType.displayName }} | </span> <span v-if="credentialType">{{ credentialType.displayName }} | </span>
<span v-show="data" <span v-show="data"
>{{ $locale.baseText('credentials.item.updated') }} <TimeAgo :date="data.updatedAt" /> | >{{ locale.baseText('credentials.item.updated') }} <TimeAgo :date="data.updatedAt" /> |
</span> </span>
<span v-show="data" <span v-show="data"
>{{ $locale.baseText('credentials.item.created') }} {{ formattedCreatedAtDate }} >{{ locale.baseText('credentials.item.created') }} {{ formattedCreatedAtDate }}
</span> </span>
</n8n-text> </n8n-text>
</div> </div>

View file

@ -407,7 +407,7 @@ async function beforeClose() {
}, },
); );
keepEditing = confirmAction === MODAL_CONFIRM; keepEditing = confirmAction === MODAL_CONFIRM;
} else if (isOAuthType.value && !isOAuthConnected.value) { } else if (credentialPermissions.value.update && isOAuthType.value && !isOAuthConnected.value) {
const confirmAction = await message.confirm( const confirmAction = await message.confirm(
i18n.baseText('credentialEdit.credentialEdit.confirmMessage.beforeClose2.message'), i18n.baseText('credentialEdit.credentialEdit.confirmMessage.beforeClose2.message'),
i18n.baseText('credentialEdit.credentialEdit.confirmMessage.beforeClose2.headline'), i18n.baseText('credentialEdit.credentialEdit.confirmMessage.beforeClose2.headline'),

View file

@ -57,7 +57,7 @@ describe('WorkflowCard', () => {
it('should render a card with the workflow name and open workflow clicking on it', async () => { it('should render a card with the workflow name and open workflow clicking on it', async () => {
const data = createWorkflow(); const data = createWorkflow();
const { getByRole } = renderComponent({ props: { data } }); const { getByRole } = renderComponent({ props: { data } });
const cardTitle = getByRole('heading', { level: 2, name: data.name }); const cardTitle = getByRole('heading', { level: 2, name: new RegExp(data.name) });
expect(cardTitle).toBeInTheDocument(); expect(cardTitle).toBeInTheDocument();
@ -166,4 +166,12 @@ describe('WorkflowCard', () => {
} }
expect(actions).toHaveTextContent('Move'); expect(actions).toHaveTextContent('Move');
}); });
it('should show Read only mode', async () => {
const data = createWorkflow();
const { getByRole } = renderComponent({ props: { data } });
const heading = getByRole('heading');
expect(heading).toHaveTextContent('Read only');
});
}); });

View file

@ -236,16 +236,19 @@ function moveResource() {
<template #header> <template #header>
<n8n-heading tag="h2" bold :class="$style.cardHeading" data-test-id="workflow-card-name"> <n8n-heading tag="h2" bold :class="$style.cardHeading" data-test-id="workflow-card-name">
{{ data.name }} {{ data.name }}
<N8nBadge v-if="!workflowPermissions.update" class="ml-3xs" theme="tertiary" bold>
{{ locale.baseText('workflows.item.readonly') }}
</N8nBadge>
</n8n-heading> </n8n-heading>
</template> </template>
<div :class="$style.cardDescription"> <div :class="$style.cardDescription">
<n8n-text color="text-light" size="small"> <n8n-text color="text-light" size="small">
<span v-show="data" <span v-show="data"
>{{ $locale.baseText('workflows.item.updated') }} >{{ locale.baseText('workflows.item.updated') }}
<TimeAgo :date="String(data.updatedAt)" /> | <TimeAgo :date="String(data.updatedAt)" /> |
</span> </span>
<span v-show="data" class="mr-2xs" <span v-show="data" class="mr-2xs"
>{{ $locale.baseText('workflows.item.created') }} {{ formattedCreatedAtDate }} >{{ locale.baseText('workflows.item.created') }} {{ formattedCreatedAtDate }}
</span> </span>
<span <span
v-if="settingsStore.areTagsEnabled && data.tags && data.tags.length > 0" v-if="settingsStore.areTagsEnabled && data.tags && data.tags.length > 0"

View file

@ -1,18 +1,15 @@
import { describe, expect } from 'vitest'; import { describe, expect } from 'vitest';
import { render } from '@testing-library/vue';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { faker } from '@faker-js/faker'; import { faker } from '@faker-js/faker';
import { createRouter, createWebHistory } from 'vue-router'; import { createRouter, createWebHistory, RouterLink } from 'vue-router';
import { createPinia, PiniaVuePlugin, setActivePinia } from 'pinia'; import { createPinia, setActivePinia } from 'pinia';
import { randomInt, type ExecutionSummary } from 'n8n-workflow'; import { randomInt, type ExecutionSummary } from 'n8n-workflow';
import { useSettingsStore } from '@/stores/settings.store'; import { useSettingsStore } from '@/stores/settings.store';
import WorkflowExecutionsPreview from '@/components/executions/workflow/WorkflowExecutionsPreview.vue'; import WorkflowExecutionsPreview from '@/components/executions/workflow/WorkflowExecutionsPreview.vue';
import { EnterpriseEditionFeature, VIEWS } from '@/constants'; import { EnterpriseEditionFeature, VIEWS } from '@/constants';
import { i18nInstance, I18nPlugin } from '@/plugins/i18n';
import { FontAwesomePlugin } from '@/plugins/icons';
import { GlobalComponentsPlugin } from '@/plugins/components';
import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowsStore } from '@/stores/workflows.store';
import type { ExecutionSummaryWithScopes, IWorkflowDb } from '@/Interface'; import type { ExecutionSummaryWithScopes, IWorkflowDb } from '@/Interface';
import { createComponentRenderer } from '@/__tests__/render';
let pinia: ReturnType<typeof createPinia>; let pinia: ReturnType<typeof createPinia>;
@ -64,6 +61,19 @@ const executionDataFactory = (): ExecutionSummaryWithScopes => ({
scopes: ['workflow:update'], scopes: ['workflow:update'],
}); });
const renderComponent = createComponentRenderer(WorkflowExecutionsPreview, {
global: {
stubs: {
// UN STUB router-link
'router-link': RouterLink,
},
plugins: [router],
mocks: {
$route,
},
},
});
describe('WorkflowExecutionsPreview.vue', () => { describe('WorkflowExecutionsPreview.vue', () => {
let settingsStore: ReturnType<typeof useSettingsStore>; let settingsStore: ReturnType<typeof useSettingsStore>;
let workflowsStore: ReturnType<typeof useWorkflowsStore>; let workflowsStore: ReturnType<typeof useWorkflowsStore>;
@ -93,30 +103,26 @@ describe('WorkflowExecutionsPreview.vue', () => {
vi.spyOn(workflowsStore, 'getWorkflowById').mockReturnValue({ scopes } as IWorkflowDb); vi.spyOn(workflowsStore, 'getWorkflowById').mockReturnValue({ scopes } as IWorkflowDb);
// Not using createComponentRenderer helper here because this component should not stub `router-link` const { getByTestId } = renderComponent({ props: { execution: executionData } });
const { getByTestId } = render(WorkflowExecutionsPreview, {
props: {
execution: executionData,
},
global: {
plugins: [
I18nPlugin,
i18nInstance,
PiniaVuePlugin,
FontAwesomePlugin,
GlobalComponentsPlugin,
pinia,
router,
],
mocks: {
$route,
},
},
});
await userEvent.click(getByTestId('execution-debug-button')); await userEvent.click(getByTestId('execution-debug-button'));
expect(router.currentRoute.value.path).toBe(path); expect(router.currentRoute.value.path).toBe(path);
}, },
); );
it('disables the stop execution button when the user cannot update', () => {
settingsStore.settings.enterprise = {
...(settingsStore.settings.enterprise ?? {}),
};
vi.spyOn(workflowsStore, 'getWorkflowById').mockReturnValue({
scopes: undefined,
} as IWorkflowDb);
const { getByTestId } = renderComponent({
props: { execution: { ...executionData, status: 'running' } },
});
expect(getByTestId('stop-execution')).toBeDisabled();
});
}); });

View file

@ -120,7 +120,13 @@ function onRetryButtonBlur(event: FocusEvent) {
<N8nText :class="$style.runningMessage" color="text-light"> <N8nText :class="$style.runningMessage" color="text-light">
{{ locale.baseText('executionDetails.runningMessage') }} {{ locale.baseText('executionDetails.runningMessage') }}
</N8nText> </N8nText>
<N8nButton class="mt-l" type="tertiary" @click="handleStopClick"> <N8nButton
data-test-id="stop-execution"
class="mt-l"
type="tertiary"
:disabled="!workflowPermissions.execute"
@click="handleStopClick"
>
{{ locale.baseText('executionsList.stopExecution') }} {{ locale.baseText('executionsList.stopExecution') }}
</N8nButton> </N8nButton>
</div> </div>

View file

@ -600,6 +600,7 @@
"credentials.item.updated": "Last updated", "credentials.item.updated": "Last updated",
"credentials.item.created": "Created", "credentials.item.created": "Created",
"credentials.item.owner": "Owner", "credentials.item.owner": "Owner",
"credentials.item.readonly": "Read only",
"credentials.search.placeholder": "Search credentials...", "credentials.search.placeholder": "Search credentials...",
"credentials.filters.type": "Type", "credentials.filters.type": "Type",
"credentials.filters.active": "Some credentials may be hidden since filters are applied.", "credentials.filters.active": "Some credentials may be hidden since filters are applied.",
@ -2206,6 +2207,7 @@
"workflows.item.move": "Move", "workflows.item.move": "Move",
"workflows.item.updated": "Last updated", "workflows.item.updated": "Last updated",
"workflows.item.created": "Created", "workflows.item.created": "Created",
"workflows.item.readonly": "Read only",
"workflows.search.placeholder": "Search workflows...", "workflows.search.placeholder": "Search workflows...",
"workflows.filters": "Filters", "workflows.filters": "Filters",
"workflows.filters.tags": "Tags", "workflows.filters.tags": "Tags",

View file

@ -76,5 +76,40 @@ describe('CredentialsView', () => {
null, null,
); );
}); });
it('should disable cards based on permissions', () => {
vi.spyOn(credentialsStore, 'allCredentials', 'get').mockReturnValue([
{
id: '1',
name: 'test',
type: 'test',
createdAt: '2021-05-05T00:00:00Z',
updatedAt: '2021-05-05T00:00:00Z',
scopes: ['credential:update'],
},
{
id: '2',
name: 'test2',
type: 'test2',
createdAt: '2021-05-05T00:00:00Z',
updatedAt: '2021-05-05T00:00:00Z',
},
]);
renderComponent();
expect(ResourcesListLayout.setup).toHaveBeenCalledWith(
expect.objectContaining({
resources: [
expect.objectContaining({
readOnly: false,
}),
expect.objectContaining({
readOnly: true,
}),
],
}),
null,
);
});
}); });
}); });

View file

@ -57,6 +57,7 @@ export default defineComponent({
scopes: credential.scopes, scopes: credential.scopes,
type: credential.type, type: credential.type,
sharedWithProjects: credential.sharedWithProjects, sharedWithProjects: credential.sharedWithProjects,
readOnly: !getResourcePermissions(credential.scopes).credential.update,
})); }));
}, },
allCredentialTypes(): ICredentialType[] { allCredentialTypes(): ICredentialType[] {
@ -179,7 +180,12 @@ export default defineComponent({
</div> </div>
</template> </template>
<template #default="{ data }"> <template #default="{ data }">
<CredentialCard data-test-id="resources-list-item" class="mb-2xs" :data="data" /> <CredentialCard
data-test-id="resources-list-item"
class="mb-2xs"
:data="data"
:read-only="data.readOnly"
/>
</template> </template>
<template #filters="{ setKeyValue }"> <template #filters="{ setKeyValue }">
<div class="mb-s"> <div class="mb-s">