chore: IAM roles & group ux fixes (#12958)

This commit is contained in:
Ashish Kolhe
2026-07-01 10:34:15 +00:00
committed by GitHub
parent e5319388dd
commit 9cf5cbbbb4
22 changed files with 1212 additions and 65 deletions
@@ -84,6 +84,24 @@ export class IamFormValidationPage {
this.updateRoleSaveBtn = '[data-test="add-user-dialog"] [data-test="o-dialog-primary-btn"]';
this.updateRoleCancelBtn = '[data-test="add-user-dialog"] [data-test="o-dialog-secondary-btn"]';
// ── Add Role "Start from" preset (new) ────────────────────────────────
this.roleStartFromCustom = '[data-test="add-role-start-from-custom-radio"]';
this.roleStartFromReadonly = '[data-test="add-role-start-from-readonly-radio"]';
// ── Edit Role / Edit Group pages (auto-route + dirty state, new) ───────
this.editRolePage = '[data-test="edit-role-page"]';
this.editRolePermsSection = '[data-test="edit-role-permissions-section"]';
this.editRolePermsTab = '[data-test="tab-permissions"]';
this.editRolePermsDirtyDot = '[data-test="tab-permissions-dirty-dot"]';
this.editGroupSection = '[data-test="edit-group-section"]';
this.editGroupRolesTab = '[data-test="tab-roles"]';
this.editGroupRolesDirtyDot= '[data-test="tab-roles-dirty-dot"]';
// ── Confirm dialog (shared) — delete blast-radius warning banner (new) ─
this.confirmDialog = '[data-test="confirm-dialog"]';
this.confirmDialogOkBtn = '[data-test="confirm-dialog"] [data-test="o-dialog-primary-btn"]';
this.confirmDialogCancelBtn= '[data-test="confirm-dialog"] [data-test="o-dialog-secondary-btn"]';
// ── Toast / success messages ──────────────────────────────────────────
this.toastSuccess = '[data-test-variant="success"]';
this.toastMessage = '[data-test="o-toast-message"]';
@@ -206,6 +224,39 @@ export class IamFormValidationPage {
await this.page.locator(this.roleCancelBtn).click();
}
// Select the "Read-only" start-from preset before submitting the role form.
async selectRoleStartFromReadonly() {
await this.page.locator(this.roleStartFromReadonly).click();
}
// Delete the first role/group in the list via its row trash icon, keyed by name.
async clickDeleteRole(roleName) {
await this.page
.locator(`[data-test="iam-roles-delete-${roleName}-role-icon"]`)
.click();
}
async clickDeleteGroup(groupName) {
await this.page
.locator(`[data-test="iam-groups-delete-${groupName}-role-icon"]`)
.click();
}
// Toggle the first available permission checkbox on the Edit Role Permissions
// tab (used to dirty the tab). Returns true if a checkbox was found+clicked.
async toggleFirstPermissionCheckbox() {
const checkbox = this.page
.locator('[data-test*="-permissions-table-body-row-"][data-test$="-checkbox"]')
.first();
try {
await checkbox.waitFor({ state: 'visible', timeout: 15000 });
await checkbox.click();
return true;
} catch {
return false;
}
}
// ── Service Account form actions ──────────────────────────────────────────
async openServiceAccountForm() {
@@ -263,6 +314,17 @@ export class IamFormValidationPage {
getRoleDialogLocator() { return this.page.locator(this.roleDialog); }
getSaDialogLocator() { return this.page.locator(this.saDialog); }
getAddUserDialogLocator() { return this.page.locator(this.addUserDialog); }
// New-behavior getters (start-from, auto-route, delete banner, dirty dots)
getRoleStartFromCustomLocator() { return this.page.locator(this.roleStartFromCustom); }
getRoleStartFromReadonlyLocator() { return this.page.locator(this.roleStartFromReadonly); }
getEditRolePageLocator() { return this.page.locator(this.editRolePage); }
getEditRolePermsSectionLocator() { return this.page.locator(this.editRolePermsSection); }
getEditRolePermsDirtyDotLocator() { return this.page.locator(this.editRolePermsDirtyDot); }
getEditGroupSectionLocator() { return this.page.locator(this.editGroupSection); }
getConfirmDialogLocator() { return this.page.locator(this.confirmDialog); }
getConfirmDialogOkBtnLocator() { return this.page.locator(this.confirmDialogOkBtn); }
getConfirmDialogCancelBtnLocator(){ return this.page.locator(this.confirmDialogCancelBtn); }
getUserEmailErrorLocator() { return this.page.locator(this.userEmailError); }
getUserPasswordErrorLocator() { return this.page.locator(this.userPasswordError); }
getUserRoleErrorLocator() { return this.page.locator(this.userRoleError); }
@@ -48,6 +48,8 @@ export class IngestionTokensPage {
// ============================================================
// Revealed token dialog
// ============================================================
// The <code> block now shows the ready-to-use "Basic base64(name:token)"
// credential (not the raw token).
this.revealedTokenCode = page.locator('[role="dialog"] code');
}
@@ -95,8 +97,16 @@ export class IngestionTokensPage {
await this.page.waitForTimeout(500);
}
// Copies the ready-to-use "Basic base64(name:token)" credential (primary btn).
async clickCopyToken() {
await this.page.getByRole('button', { name: 'Copy' }).last().click();
await this.page
.getByRole('button', { name: 'Copy Authorization header' })
.click();
}
// Copies the raw o2oi_ token (secondary btn).
async clickCopyRawToken() {
await this.page.getByRole('button', { name: 'Copy raw token' }).click();
}
// ----- toggle -----
@@ -158,7 +158,7 @@ test.describe("IAM Group form validation", { tag: '@enterprise' }, () => {
await pm.iamFormValidation.fillGroupName('group with spaces');
await expect(pm.iamFormValidation.getGroupNameErrorLocator()).toBeVisible();
await expect(pm.iamFormValidation.getGroupNameErrorLocator()).toContainText(
"Use alphanumeric and '_' characters only, without spaces."
"Use letters, numbers and underscores only, without spaces."
);
await expect(pm.iamFormValidation.getGroupSubmitBtnLocator()).toBeDisabled();
@@ -173,7 +173,7 @@ test.describe("IAM Group form validation", { tag: '@enterprise' }, () => {
await pm.iamFormValidation.openGroupForm();
await pm.iamFormValidation.fillGroupName('group@name!');
await expect(pm.iamFormValidation.getGroupNameErrorLocator()).toBeVisible();
await expect(pm.iamFormValidation.getGroupNameErrorLocator()).toContainText("Use alphanumeric and '_' characters only, without spaces.");
await expect(pm.iamFormValidation.getGroupNameErrorLocator()).toContainText("Use letters, numbers and underscores only, without spaces.");
await expect(pm.iamFormValidation.getGroupSubmitBtnLocator()).toBeDisabled();
testLogger.info('Group special-char error correctly shown');
@@ -194,6 +194,46 @@ test.describe("IAM Group form validation", { tag: '@enterprise' }, () => {
testLogger.info('Group created successfully');
});
test("should auto-route to Edit Group after create", {
tag: ['@iamFormValidation', '@functional', '@P1']
}, async ({ page }) => {
const groupName = `e2e_iam_group_${Date.now()}`;
testLogger.info(`Creating group and expecting auto-route: ${groupName}`);
await pm.iamFormValidation.openGroupForm();
await pm.iamFormValidation.fillGroupName(groupName);
await pm.iamFormValidation.submitGroupForm();
// Lands on the Edit Group page instead of returning to the list.
await expect(pm.iamFormValidation.getEditGroupSectionLocator()).toBeVisible({ timeout: 15000 });
await expect(page).toHaveURL(/groups\/edit\//);
testLogger.info('Auto-routed to Edit Group');
});
test("should warn about access loss in the group delete confirm dialog", {
tag: ['@iamFormValidation', '@functional', '@P1']
}, async ({ page }) => {
const groupName = `e2e_iam_group_${Date.now()}`;
testLogger.info(`Creating then deleting group to check blast-radius: ${groupName}`);
await pm.iamFormValidation.openGroupForm();
await pm.iamFormValidation.fillGroupName(groupName);
await pm.iamFormValidation.submitGroupForm();
await expect(pm.iamFormValidation.getEditGroupSectionLocator()).toBeVisible({ timeout: 15000 });
// Back to the groups list, then delete.
await pm.iamFormValidation.navigateToGroupsTab();
await pm.iamFormValidation.clickDeleteGroup(groupName);
await expect(pm.iamFormValidation.getConfirmDialogLocator()).toBeVisible();
await expect(pm.iamFormValidation.getConfirmDialogLocator()).toContainText(/lose the roles/i);
await pm.iamFormValidation.getConfirmDialogOkBtnLocator().click();
testLogger.info('Group delete blast-radius warning shown');
});
});
// ── Role form (@enterprise — Roles tab requires isEnterprise && rbac_enabled) ──
@@ -239,7 +279,7 @@ test.describe("IAM Role form validation", { tag: '@enterprise' }, () => {
await pm.iamFormValidation.fillRoleName('role with spaces');
await expect(pm.iamFormValidation.getRoleNameErrorLocator()).toBeVisible();
await expect(pm.iamFormValidation.getRoleNameErrorLocator()).toContainText(
"Use alphanumeric and '_' characters only, without spaces."
"Use letters, numbers and underscores only, without spaces."
);
await expect(pm.iamFormValidation.getRoleSubmitBtnLocator()).toBeDisabled();
@@ -254,7 +294,7 @@ test.describe("IAM Role form validation", { tag: '@enterprise' }, () => {
await pm.iamFormValidation.openRoleForm();
await pm.iamFormValidation.fillRoleName('bad role name!');
await expect(pm.iamFormValidation.getRoleNameErrorLocator()).toBeVisible();
await expect(pm.iamFormValidation.getRoleNameErrorLocator()).toContainText("Use alphanumeric and '_' characters only, without spaces.");
await expect(pm.iamFormValidation.getRoleNameErrorLocator()).toContainText("Use letters, numbers and underscores only, without spaces.");
await pm.iamFormValidation.fillRoleName('valid_role_name');
await expect(pm.iamFormValidation.getRoleNameErrorLocator()).not.toBeVisible();
@@ -278,6 +318,94 @@ test.describe("IAM Role form validation", { tag: '@enterprise' }, () => {
testLogger.info('Role created successfully');
});
test("should show 'Start from' preset options in the Add Role dialog", {
tag: ['@iamFormValidation', '@functional', '@P1']
}, async ({ page }) => {
testLogger.info('Testing Start-from preset options render');
await pm.iamFormValidation.openRoleForm();
await expect(pm.iamFormValidation.getRoleStartFromCustomLocator()).toBeVisible();
await expect(pm.iamFormValidation.getRoleStartFromReadonlyLocator()).toBeVisible();
testLogger.info('Start-from Custom + Read-only options rendered');
});
test("should auto-route to Edit Role Permissions tab after create", {
tag: ['@iamFormValidation', '@functional', '@P1']
}, async ({ page }) => {
const roleName = `e2e_iam_role_${Date.now()}`;
testLogger.info(`Creating role and expecting auto-route: ${roleName}`);
await pm.iamFormValidation.openRoleForm();
await pm.iamFormValidation.fillRoleName(roleName);
await pm.iamFormValidation.submitRoleForm();
// Instead of returning to the list, we land on the Edit Role page with the
// Permissions tab active.
await expect(pm.iamFormValidation.getEditRolePageLocator()).toBeVisible({ timeout: 15000 });
await expect(pm.iamFormValidation.getEditRolePermsSectionLocator()).toBeVisible();
await expect(page).toHaveURL(/roles\/edit\//);
testLogger.info('Auto-routed to Edit Role Permissions tab');
});
test("should warn about access loss in the delete confirm dialog", {
tag: ['@iamFormValidation', '@functional', '@P1']
}, async ({ page }) => {
const roleName = `e2e_iam_role_${Date.now()}`;
testLogger.info(`Creating then deleting role to check blast-radius: ${roleName}`);
// Create a role, then return to the list to delete it.
await pm.iamFormValidation.openRoleForm();
await pm.iamFormValidation.fillRoleName(roleName);
await pm.iamFormValidation.submitRoleForm();
await expect(pm.iamFormValidation.getEditRolePageLocator()).toBeVisible({ timeout: 15000 });
// Back to the roles list.
await pm.iamFormValidation.navigateToRolesTab();
await pm.iamFormValidation.clickDeleteRole(roleName);
// Confirm dialog shows a blast-radius warning banner mentioning access loss.
await expect(pm.iamFormValidation.getConfirmDialogLocator()).toBeVisible();
await expect(pm.iamFormValidation.getConfirmDialogLocator()).toContainText(/lose access/i);
// Confirm the delete to clean up.
await pm.iamFormValidation.getConfirmDialogOkBtnLocator().click();
testLogger.info('Delete confirm blast-radius warning shown');
});
test("should mark the Permissions tab dirty and prompt on leave with unsaved changes", {
tag: ['@iamFormValidation', '@functional', '@P2']
}, async ({ page }) => {
const roleName = `e2e_iam_role_${Date.now()}`;
testLogger.info(`Testing dirty dot + discard guard: ${roleName}`);
await pm.iamFormValidation.openRoleForm();
await pm.iamFormValidation.fillRoleName(roleName);
await pm.iamFormValidation.submitRoleForm();
await expect(pm.iamFormValidation.getEditRolePageLocator()).toBeVisible({ timeout: 15000 });
// Toggle a permission to dirty the Permissions tab.
const toggled = await pm.iamFormValidation.toggleFirstPermissionCheckbox();
if (!toggled) {
test.skip(true, 'No permission checkboxes rendered for this org — cannot exercise dirty state');
return;
}
await expect(pm.iamFormValidation.getEditRolePermsDirtyDotLocator()).toBeVisible();
// Attempt to leave — a discard-changes confirm dialog should appear.
await pm.iamFormValidation.navigateToRolesTab();
await expect(pm.iamFormValidation.getConfirmDialogLocator()).toBeVisible();
await expect(pm.iamFormValidation.getConfirmDialogLocator()).toContainText(/discard/i);
// Discard and continue.
await pm.iamFormValidation.getConfirmDialogOkBtnLocator().click();
testLogger.info('Dirty dot + discard-changes guard verified');
});
});
// ── Service Account email format validation ───────────────────────────────────
@@ -67,12 +67,13 @@ test.describe("Org-Level Ingestion Tokens", () => {
// Should see success toast
await pageManager.ingestionTokensPage.verifySuccessMessage('Token created successfully.');
// Revealed dialog should show the token
// Revealed dialog shows the ready-to-use "Basic base64(name:token)"
// credential (not the raw token) so it can be pasted straight into an
// Authorization header.
const revealedCode = pageManager.ingestionTokensPage.revealedTokenCode;
await expect(revealedCode).toBeVisible({ timeout: 5000 });
const tokenText = await revealedCode.textContent();
expect(tokenText).toMatch(/^o2oi_/);
expect(tokenText.length).toBe(37);
const credText = await revealedCode.textContent();
expect(credText).toMatch(/^Basic /);
// Close revealed dialog
await pageManager.ingestionTokensPage.closeRevealedDialog();
@@ -135,19 +136,23 @@ test.describe("Org-Level Ingestion Tokens", () => {
await pageManager.ingestionTokensPage.fillTokenName(uniqueName);
await pageManager.ingestionTokensPage.clickCreate();
// Wait for revealed dialog
// Wait for revealed dialog — shows the ready-to-use Basic credential.
const revealedCode = pageManager.ingestionTokensPage.revealedTokenCode;
await expect(revealedCode).toBeVisible({ timeout: 5000 });
const tokenText = await revealedCode.textContent();
expect(tokenText).toMatch(/^o2oi_/);
const credText = await revealedCode.textContent();
expect(credText).toMatch(/^Basic /);
// Grant clipboard permissions and copy
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
await pageManager.ingestionTokensPage.clickCopyToken();
// Verify clipboard contains the token
const clipboardText = await page.evaluate(() => navigator.clipboard.readText());
expect(clipboardText).toBe(tokenText);
// Primary "Copy Authorization header" copies the Basic credential shown.
await pageManager.ingestionTokensPage.clickCopyToken();
const authClipboard = await page.evaluate(() => navigator.clipboard.readText());
expect(authClipboard).toBe(credText);
// Secondary "Copy raw token" copies the raw o2oi_ token.
await pageManager.ingestionTokensPage.clickCopyRawToken();
const rawClipboard = await page.evaluate(() => navigator.clipboard.readText());
expect(rawClipboard).toMatch(/^o2oi_/);
// Close dialog
await pageManager.ingestionTokensPage.closeRevealedDialog();
@@ -214,6 +214,54 @@ test.describe("Service Account for API access", () => {
testLogger.info('Test completed successfully');
});
test("Token wizard Grant permissions step shows role/group actions", async ({ page }, testInfo) => {
const uniqueEmail = `email${Date.now()}_${Math.floor(Math.random() * 10000)}@gmail.com`;
testLogger.testStart(testInfo.title, testInfo.file);
await navigateToBase(page);
pageManager = new PageManager(page);
await pageManager.iamPage.gotoIamPage();
await pageManager.iamPage.iamPageServiceAccountsTab();
await waitForServiceAccountsPage(page);
await pageManager.iamPage.iamPageAddServiceAccount();
await pageManager.iamPage.enterEmailServiceAccount(uniqueEmail);
await pageManager.iamPage.clickSaveServiceAccount();
await pageManager.iamPage.verifySuccessMessage('Service Account created successfully.');
// Advance the token wizard to step 2 (Grant permissions).
const nextBtn = page.locator('[data-test="service-accounts-token-next-btn"]');
await expect(nextBtn).toBeVisible({ timeout: 10000 });
await nextBtn.click();
// The two grant actions only render on Enterprise/Cloud (showGroupLink =
// isEnterprise || isCloud). On an OSS build step 2 shows a plain hint with
// no links, so gate-skip the label assertions there instead of failing.
const roleLink = page.locator('[data-test="service-accounts-list-token-add-to-role"]');
const groupLink = page.locator('[data-test="service-accounts-list-token-add-to-group"]');
const grantLinksRendered = await roleLink
.waitFor({ state: 'visible', timeout: 5000 })
.then(() => true)
.catch(() => false);
if (!grantLinksRendered) {
testLogger.info('Grant links absent (OSS build) — skipping label assertions');
await page.locator('[data-test="service-accounts-token-done-btn"]').click();
test.skip(true, 'Token wizard grant links are Enterprise/Cloud-only (showGroupLink=false on OSS)');
return;
}
// Step 2 offers the two grant actions with the reworded labels.
await expect(groupLink).toBeVisible();
await expect(roleLink).toContainText('Assign a role');
await expect(groupLink).toContainText('Add to a user group');
// Close the wizard.
await page.locator('[data-test="service-accounts-token-done-btn"]').click();
testLogger.info('Grant permissions step verified');
});
test("SRE Agent System Account Protection", async ({ page }, testInfo) => {
testLogger.testStart(testInfo.title, testInfo.file);
+52
View File
@@ -128,4 +128,56 @@ describe("AppTabs", () => {
// Hidden tab is not in DOM
expect(wrapper.find('[data-test="tab-tab4"]').exists()).toBe(false);
});
describe("dirty dot", () => {
it("renders a dirty dot when tab.dirty is true", () => {
const dirtyWrapper = mount(AppTabs, {
props: {
tabs: [
{ label: "Clean", value: "clean" },
{ label: "Dirty", value: "dirty", dirty: true },
],
activeTab: "clean",
},
});
expect(
dirtyWrapper.find('[data-test="tab-dirty-dirty-dot"]').exists(),
).toBe(true);
expect(
dirtyWrapper.find('[data-test="tab-clean-dirty-dot"]').exists(),
).toBe(false);
dirtyWrapper.unmount();
});
it("renders no dot when dirty is false or undefined (existing callers unaffected)", () => {
// mockTabs has no `dirty` prop on any tab — none should render a dot.
const dots = wrapper.findAll('[data-test$="-dirty-dot"]');
expect(dots).toHaveLength(0);
const falseWrapper = mount(AppTabs, {
props: {
tabs: [{ label: "Tab", value: "tab", dirty: false }],
activeTab: "tab",
},
});
expect(falseWrapper.find('[data-test="tab-tab-dirty-dot"]').exists()).toBe(
false,
);
falseWrapper.unmount();
});
it("applies dirtyTitle to the dot when provided", () => {
const titledWrapper = mount(AppTabs, {
props: {
tabs: [{ label: "Dirty", value: "dirty", dirty: true }],
activeTab: "dirty",
dirtyTitle: "Unsaved changes",
},
});
const dot = titledWrapper.find('[data-test="tab-dirty-dirty-dot"]');
expect(dot.attributes("title")).toBe("Unsaved changes");
titledWrapper.unmount();
});
});
});
+13
View File
@@ -35,6 +35,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<component v-else :is="tab.icon" class="tw:size-3.5 tw:shrink-0" />
</template>
{{ tab.label }}
<span
v-if="tab.dirty"
class="tw:ml-1.5 tw:w-2 tw:h-2 tw:rounded-full tw:bg-button-primary tw:shrink-0"
:title="dirtyTitle"
:data-test="`tab-${tab.value}-dirty-dot`"
aria-hidden="true"
/>
<OTooltip v-if="tab.tooltipLabel" :content="tab.tooltipLabel" />
</OToggleGroupItem>
</OToggleGroup>
@@ -58,6 +65,9 @@ interface Tab {
tooltipLabel?: string;
hide?: boolean;
icon?: Component | string;
// When true, renders a small unsaved-changes dot to the right of the label.
// Optional and defaults to undefined, so existing callers are unaffected.
dirty?: boolean;
}
const emit = defineEmits(["update:activeTab"]);
@@ -68,10 +78,13 @@ const props = withDefaults(
tabs: Tab[];
activeTab: string;
size?: ToggleGroupItemSize;
// Tooltip shown when hovering an unsaved-changes dot (optional).
dirtyTitle?: string;
}>(),
{
show: true,
size: "sm",
dirtyTitle: "",
}
);
+43 -7
View File
@@ -92,8 +92,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<span class="tw:font-medium">{{ row.name }}</span>
</template>
<!-- Copy yields a ready-to-paste "Basic base64(name:token)" credential
(OCodeCell copies exactly what it shows), so no manual email +
base64 step is needed. -->
<template #cell-token="{ row }">
<OCodeCell :value="row.token" />
<OCodeCell :value="toBasicAuth(row.name, row.token)" />
</template>
<template #cell-created_by="{ row }">
@@ -149,13 +152,20 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:secondary-button-label="t('common.close')"
@click:secondary="showRevealedDialog = false"
>
<!-- Primary: the ready-to-paste Authorization credential. -->
<div class="tw:mb-1 tw:text-xs tw:font-medium tw:text-text-primary">
{{ t('ingestion.authHeaderLabel') }}
</div>
<div
class="tw:p-2.5 tw:border tw:border-dashed tw:rounded tw:border-gray-400 tw:mb-4"
class="tw:p-2.5 tw:border tw:border-dashed tw:rounded tw:border-gray-400 tw:mb-1"
:class="store.state.theme === 'dark' ? 'tw:bg-gray-800' : 'tw:bg-gray-100'"
>
<code
class="tw:break-all tw:font-mono tw:text-sm"
>{{ revealedToken?.token }}</code>
>{{ revealedBasicAuth }}</code>
</div>
<div class="tw:mb-3 tw:text-xs tw:text-text-secondary">
{{ t('ingestion.authHeaderHelp') }}
</div>
<div class="tw:flex tw:justify-end tw:gap-2">
<OButton
@@ -164,7 +174,15 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
icon="content-copy"
@click="copyToken(revealedToken?.token || '')"
>
{{ t('ingestion.copyTokenBtn') }}
{{ t('ingestion.copyRawTokenBtn') }}
</OButton>
<OButton
variant="primary"
size="sm-action"
icon="content-copy"
@click="copyToken(revealedBasicAuth)"
>
{{ t('ingestion.copyAuthHeaderBtn') }}
</OButton>
</div>
</ODialog>
@@ -172,7 +190,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</template>
<script lang="ts">
import { ref, defineComponent, onBeforeMount } from "vue";
import { ref, computed, defineComponent, onBeforeMount } from "vue";
import { useStore } from "vuex";
import { useI18n } from "vue-i18n";
import OButton from "@/lib/core/Button/OButton.vue";
@@ -188,6 +206,7 @@ import OUserCell from "@/lib/core/Table/cells/OUserCell.vue";
import { COL, type OTableColumnDef } from "@/lib/core/Table/OTable.types";
import { toast } from "@/lib/feedback/Toast/useToast";
import { copyToClipboard } from "@/utils/clipboard";
import { getBasicAuth } from "@/utils/auth";
import organizationsService from "@/services/organizations";
import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
@@ -217,6 +236,13 @@ export default defineComponent({
const newTokenDescription = ref("");
const revealedToken = ref<{ name: string; token: string } | null>(null);
// Ready-to-paste "Basic base64(name:token)" for the just-created token.
const revealedBasicAuth = computed(() =>
revealedToken.value
? getBasicAuth(revealedToken.value.name, revealedToken.value.token)
: "",
);
const columns: OTableColumnDef<Token>[] = [
{
id: "name",
@@ -231,12 +257,12 @@ export default defineComponent({
},
{
id: "token",
header: t("serviceAccounts.token"),
header: t("ingestion.tokenAuthHeader"),
accessorKey: "token",
sortable: false,
resizable: true,
hideable: true,
// Wide enough for the truncated token (code max-w 280) + gap + copy btn.
// Wide enough for the truncated credential + gap + copy btn.
size: 340,
meta: { align: "left" },
},
@@ -343,6 +369,14 @@ export default defineComponent({
}
};
// Build a ready-to-paste Authorization value: "Basic base64(name:token)".
// The username part is the TOKEN NAME (not a user email): org ingestion
// tokens are org-scoped and the backend ignores the username, so using the
// token name keeps the credential person-independent (never goes stale when
// a user leaves) and gives meaningful ingestion-log attribution.
const toBasicAuth = (name: string, token: string) =>
getBasicAuth(name, token);
const copyToken = (token: string) => {
copyToClipboard(token);
};
@@ -363,10 +397,12 @@ export default defineComponent({
newTokenName,
newTokenDescription,
revealedToken,
revealedBasicAuth,
fetchTokens,
createToken,
toggleEnabled,
copyToken,
toBasicAuth,
};
},
});
@@ -152,7 +152,7 @@ describe("AddGroup Component", () => {
await flushPromises();
expect(wrapper.text()).toContain(
"Use alphanumeric and '_' characters only, without spaces.",
"Use letters, numbers and underscores only, without spaces.",
);
});
@@ -286,7 +286,9 @@ describe("AddGroup Component", () => {
store.state.selectedOrganization.identifier,
);
expect(wrapper.emitted("added:group")).toBeTruthy();
expect(wrapper.emitted("added:group")?.[0]).toEqual([mockResponse.data]);
expect(wrapper.emitted("added:group")?.[0]).toEqual([
{ group_name: "test_group", data: mockResponse.data },
]);
const updateOpen = wrapper.emitted("update:open");
expect(updateOpen).toBeTruthy();
+7 -2
View File
@@ -77,6 +77,9 @@ const { track } = useReo();
const store = useStore();
const isValidGroupName = computed(() => {
// Keep in sync with the backend, which strips everything except
// [a-zA-Z0-9_] via format_role_name_only() (jwt.rs). Hyphens are NOT
// allowed: the backend would silently rewrite "my-group" → "my_group".
const roleNameRegex = /^[a-zA-Z0-9_]+$/;
return roleNameRegex.test(name.value);
});
@@ -93,14 +96,16 @@ watch(
}
);
const nameErrorMessage = computed(() =>
!name.value ? t('common.nameRequired') : t('iam.nameHelpText')
!name.value
? t("iam.group.name.required")
: t("iam.group.name.invalidChars")
);
const saveGroup = () => {
if (!name.value || !isValidGroupName.value) return;
createGroup(name.value, store.state.selectedOrganization.identifier)
.then((res) => {
emits("added:group", res.data);
emits("added:group", { group_name: name.value, data: res.data });
emits("update:open", false);
toast({
@@ -24,6 +24,12 @@ import router from "@/test/unit/helpers/router";
vi.mock("@/services/iam", () => ({
getGroups: vi.fn(),
deleteGroup: vi.fn(),
bulkDeleteGroups: vi.fn(async () => ({
data: { successful: [], unsuccessful: [] },
})),
getGroup: vi.fn(async () => ({
data: { name: "dev", users: ["u1@o2.ai", "u2@o2.ai"], roles: ["admin"] },
})),
}));
vi.mock("@/services/reodotdev_analytics", () => ({
@@ -326,14 +332,79 @@ describe("AppGroups Component", () => {
it("shows confirm dialog when delete icon is clicked", async () => {
const testGroup = { group_name: "test-group" };
wrapper.vm.showConfirmDialog(testGroup);
expect(wrapper.vm.deleteConformDialog.show).toBe(true);
expect(wrapper.vm.deleteConformDialog.data).toEqual(testGroup);
});
});
describe("Create flow auto-route", () => {
it("routes to editGroup on the roles tab after create", async () => {
const routerPushSpy = vi
.spyOn(router, "push")
.mockResolvedValue(undefined as any);
await wrapper.vm.onGroupAdded({ group_name: "NewGroup" });
expect(routerPushSpy).toHaveBeenCalledWith(
expect.objectContaining({
name: "editGroup",
params: { group_name: "NewGroup" },
query: expect.objectContaining({ tab: "roles" }),
}),
);
});
it("falls back to refreshing the list when no group_name is provided", async () => {
const { getGroups } = await import("@/services/iam");
const routerPushSpy = vi
.spyOn(router, "push")
.mockResolvedValue(undefined as any);
routerPushSpy.mockClear();
vi.mocked(getGroups).mockClear();
vi.mocked(getGroups).mockResolvedValue(
createMockAxiosResponse([]) as any,
);
await wrapper.vm.onGroupAdded({});
expect(routerPushSpy).not.toHaveBeenCalled();
expect(getGroups).toHaveBeenCalled();
});
});
describe("Delete blast-radius warning", () => {
it("builds a single-delete warning with the live member count", async () => {
const { getGroup } = await import("@/services/iam");
await wrapper.vm.showConfirmDialog({ group_name: "dev" });
await flushPromises();
expect(getGroup).toHaveBeenCalledWith(
"dev",
store.state.selectedOrganization.identifier,
);
// Two mocked members → message mentions "2".
expect(wrapper.vm.deleteImpactMessage).toContain("2");
});
it("shows the live member count when exactly one group is bulk-selected", async () => {
wrapper.vm.selectedGroups = [{ group_name: "dev" }];
await wrapper.vm.openBulkDeleteDialog();
await flushPromises();
expect(wrapper.vm.bulkDeleteImpactMessage).toContain("2");
});
it("uses static copy (no fetch) when multiple groups are bulk-selected", async () => {
const { getGroup } = await import("@/services/iam");
vi.mocked(getGroup).mockClear();
wrapper.vm.selectedGroups = [
{ group_name: "dev" },
{ group_name: "ops" },
];
await wrapper.vm.openBulkDeleteDialog();
await flushPromises();
expect(getGroup).not.toHaveBeenCalled();
expect(wrapper.vm.bulkDeleteImpactMessage).toBeTruthy();
});
});
describe("Group Deletion", () => {
beforeEach(() => {
mockToast.mockClear();
+77 -5
View File
@@ -20,10 +20,14 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
table's own toolbar (built-in global filter). -->
<AppPageHeader
:title="t('iam.groups')"
:subtitle="'Group users together to assign roles'"
icon="group"
class="tw:shrink-0 tw:px-4 tw:border-b tw:border-border-default"
>
<template #subtitle>
<span data-test="iam-groups-subtitle">
{{ t('iam.groupsPage.subtitle') }}
</span>
</template>
<template #actions>
<OButton
data-test="iam-groups-add-group-btn"
@@ -116,11 +120,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<AddGroup
v-model:open="showAddGroup"
:org_identifier="store.state.selectedOrganization.identifier"
@added:group="setupGroups"
@added:group="onGroupAdded"
/>
<ConfirmDialog
title="Delete Group"
:message="`Are you sure you want to delete '${deleteConformDialog?.data?.group_name as string}'?`"
:warning-message="deleteImpactMessage"
@update:ok="_deleteGroup"
@update:cancel="deleteConformDialog.show = false"
v-model="deleteConformDialog.show"
@@ -128,6 +133,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<ConfirmDialog
title="Bulk Delete Groups"
:message="`Are you sure you want to delete ${selectedGroups.length} group(s)?`"
:warning-message="bulkDeleteImpactMessage"
@update:ok="bulkDeleteUserGroups"
@update:cancel="confirmBulkDelete = false"
v-model="confirmBulkDelete"
@@ -147,7 +153,7 @@ import { useI18n } from "vue-i18n";
import { cloneDeep } from "lodash-es";
import { useRouter } from "vue-router";
import { useStore } from "vuex";
import { getGroups, deleteGroup, bulkDeleteGroups } from "@/services/iam";
import { getGroups, deleteGroup, bulkDeleteGroups, getGroup } from "@/services/iam";
import usePermissions from "@/composables/iam/usePermissions";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
import { useReo } from "@/services/reodotdev_analytics";
@@ -244,6 +250,27 @@ const addGroup = () => {
showAddGroup.value = true;
};
// After a group is created, route straight into EditGroup on the Roles tab so
// the user can start assigning roles instead of being dropped back on the list
// with an empty group.
const onGroupAdded = (payload: { group_name: string; data?: any }) => {
if (!payload?.group_name) {
setupGroups();
return;
}
router.push({
name: "editGroup",
params: {
group_name: payload.group_name,
},
query: {
org_identifier: store.state.selectedOrganization.identifier,
tab: "roles",
},
});
};
const editGroup = (group: any) => {
router.push({
name: "editGroup",
@@ -298,9 +325,31 @@ const deleteUserGroup = (group: any) => {
});
};
const showConfirmDialog = (row: any) => {
// Blast-radius warning for the single-group delete dialog. We resolve the live
// member count with one getGroup call on delete-click (the group detail payload
// carries the users array; the list payload does not).
const deleteImpactMessage = ref("");
const fetchGroupMemberCount = async (groupName: string): Promise<number> => {
const res = await getGroup(
groupName,
store.state.selectedOrganization.identifier,
);
return Array.isArray(res.data?.users) ? res.data.users.length : 0;
};
const showConfirmDialog = async (row: any) => {
deleteConformDialog.value.show = true;
deleteConformDialog.value.data = row;
deleteImpactMessage.value = t("iam.groupsPage.delete.impact", { count: 0 });
try {
const count = await fetchGroupMemberCount(row.group_name);
deleteImpactMessage.value = t("iam.groupsPage.delete.impact", { count });
} catch (err) {
// If the count lookup fails, keep the generic warning rather than blocking.
console.log(err);
}
};
const _deleteGroup = () => {
@@ -308,8 +357,31 @@ const _deleteGroup = () => {
deleteConformDialog.value.data = null;
};
const openBulkDeleteDialog = () => {
// Blast-radius warning for the bulk-delete dialog. With exactly one group
// selected we resolve its live member count, matching the per-row delete. For
// 2+ groups we keep static copy to avoid N requests.
const bulkDeleteImpactMessage = ref("");
const openBulkDeleteDialog = async () => {
confirmBulkDelete.value = true;
if (selectedGroups.value.length === 1) {
bulkDeleteImpactMessage.value = t("iam.groupsPage.delete.impact", {
count: 0,
});
try {
const count = await fetchGroupMemberCount(
selectedGroups.value[0].group_name,
);
bulkDeleteImpactMessage.value = t("iam.groupsPage.delete.impact", {
count,
});
} catch (err) {
console.log(err);
}
} else {
bulkDeleteImpactMessage.value = t("iam.groupsPage.bulkDelete.impact");
}
};
const bulkDeleteUserGroups = async () => {
@@ -560,4 +560,48 @@ describe("EditGroup Component", () => {
});
});
});
describe("Per-tab dirty state", () => {
it("is not dirty on initial load", () => {
expect(wrapper.vm.isAnyDirty).toBe(false);
const rolesTab = wrapper.vm.tabs.find((t: any) => t.value === "roles");
expect(rolesTab.dirty).toBe(false);
});
it("marks only the Roles tab dirty on a role change", async () => {
wrapper.vm.addedRoles.add("admin");
await wrapper.vm.$nextTick();
const rolesTab = wrapper.vm.tabs.find((t: any) => t.value === "roles");
const usersTab = wrapper.vm.tabs.find((t: any) => t.value === "users");
expect(rolesTab.dirty).toBe(true);
expect(usersTab.dirty).toBe(false);
expect(wrapper.vm.isAnyDirty).toBe(true);
});
it("tracks Users and Service Accounts dirty state independently", async () => {
wrapper.vm.addedServiceAccounts.add("svc@o2.ai");
await wrapper.vm.$nextTick();
const usersTab = wrapper.vm.tabs.find((t: any) => t.value === "users");
const saTab = wrapper.vm.tabs.find(
(t: any) => t.value === "serviceAccounts",
);
expect(saTab?.dirty).toBe(true);
expect(usersTab.dirty).toBe(false);
});
it("merges users and service accounts into the save payload", async () => {
const { updateGroup } = await import("@/services/iam");
vi.mocked(updateGroup).mockResolvedValue({ data: {} } as any);
wrapper.vm.addedUsers.add("user@o2.ai");
wrapper.vm.addedServiceAccounts.add("svc@o2.ai");
await wrapper.vm.saveGroupChanges();
expect(updateGroup).toHaveBeenCalledWith(
expect.objectContaining({
payload: expect.objectContaining({
add_users: expect.arrayContaining(["user@o2.ai", "svc@o2.ai"]),
}),
}),
);
});
});
});
+83 -7
View File
@@ -31,6 +31,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
data-test="edit-group-tabs"
:tabs="tabs"
:active-tab="activeTab"
:dirty-title="t('iam.editGroup.unsavedDot.title')"
@update:active-tab="updateActiveTab"
/>
</div>
@@ -56,8 +57,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
v-show="activeTab === 'serviceAccounts'"
:groupUsers="groupDetails.users"
:activeTab="activeTab"
:added-users="addedUsers"
:removed-users="removedUsers"
:added-users="addedServiceAccounts"
:removed-users="removedServiceAccounts"
/>
</div>
<div
@@ -84,6 +85,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</OButton>
</div>
</div>
<ConfirmDialog
:title="t('iam.editGroup.leaveConfirm.title')"
:message="t('iam.editGroup.leaveConfirm.message')"
@update:ok="onLeaveConfirm(true)"
@update:cancel="onLeaveConfirm(false)"
v-model="leaveConfirm.show"
/>
</div>
</template>
@@ -96,12 +104,13 @@ import GroupUsers from "./GroupUsers.vue";
import AppTabs from "@/components/common/AppTabs.vue";
import AppPageHeader from "@/components/common/AppPageHeader.vue";
import { useI18n } from "vue-i18n";
import { useRouter } from "vue-router";
import { useRouter, onBeforeRouteLeave } from "vue-router";
import { onBeforeMount } from "vue";
import { getGroup, updateGroup } from "@/services/iam";
import { useStore } from "vuex";
import usePermissions from "@/composables/iam/usePermissions";
import GroupServiceAccounts from "./GroupServiceAccounts.vue";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
import { toast } from "@/lib/feedback/Toast/useToast";
onBeforeMount(() => {
@@ -131,17 +140,67 @@ const removedUsers = ref(new Set());
const addedRoles = ref(new Set());
const removedRoles = ref(new Set());
// Service-account membership is staged separately from users so the Users and
// Service Accounts tabs track dirty state independently (they're sent together
// as users in the save payload, since the backend treats both as principals).
const addedServiceAccounts = ref(new Set());
const removedServiceAccounts = ref(new Set());
// Per-tab unsaved-changes flags. Each tab tracks only its own pending changes.
const isRolesDirty = computed(
() => addedRoles.value.size > 0 || removedRoles.value.size > 0,
);
const isUsersDirty = computed(
() => addedUsers.value.size > 0 || removedUsers.value.size > 0,
);
const isServiceAccountsDirty = computed(
() =>
addedServiceAccounts.value.size > 0 ||
removedServiceAccounts.value.size > 0,
);
const isAnyDirty = computed(
() =>
isRolesDirty.value || isUsersDirty.value || isServiceAccountsDirty.value,
);
// Route-leave guard: warn before discarding unsaved role/membership changes.
// The pending navigation is held until the user resolves the dialog.
const leaveConfirm = ref<{
show: boolean;
resolve: ((proceed: boolean) => void) | null;
}>({ show: false, resolve: null });
const onLeaveConfirm = (proceed: boolean) => {
leaveConfirm.value.show = false;
leaveConfirm.value.resolve?.(proceed);
leaveConfirm.value.resolve = null;
};
onBeforeRouteLeave(() => {
if (!isAnyDirty.value) return true;
return new Promise<boolean>((resolve) => {
leaveConfirm.value.resolve = resolve;
leaveConfirm.value.show = true;
});
});
const tabs = computed(() => {
const baseTabs = [
{
value: "roles",
label: "Roles",
icon: "shield",
dirty: isRolesDirty.value,
},
{
value: "users",
label: "Users",
icon: "group",
dirty: isUsersDirty.value,
},
];
@@ -150,6 +209,7 @@ const tabs = computed(() => {
value: "serviceAccounts",
label: "Service Accounts",
icon: "smart-toy",
dirty: isServiceAccountsDirty.value,
});
}
@@ -190,9 +250,15 @@ const updateActiveTab = (tab: string) => {
};
const saveGroupChanges = () => {
// Users and service accounts are both sent as users; merge the two staging
// sets (dedup via Set) for the request payload.
const payload = {
add_users: Array.from(addedUsers.value) as string[],
remove_users: Array.from(removedUsers.value) as string[],
add_users: Array.from(
new Set([...addedUsers.value, ...addedServiceAccounts.value]),
) as string[],
remove_users: Array.from(
new Set([...removedUsers.value, ...removedServiceAccounts.value]),
) as string[],
add_roles: Array.from(addedRoles.value) as string[],
remove_roles: Array.from(removedRoles.value) as string[],
};
@@ -237,18 +303,28 @@ const saveGroupChanges = () => {
removedRoles.value = new Set([]);
// Reset Users
// Reset Users + Service Accounts (both stored in groupDetails.users)
groupDetails.value.users = groupDetails.value.users.filter(
(user) => !removedUsers.value.has(user)
(user) =>
!removedUsers.value.has(user) &&
!removedServiceAccounts.value.has(user)
);
addedUsers.value.forEach((value: any) => {
groupDetails.value.users.push(value as string);
});
addedServiceAccounts.value.forEach((value: any) => {
groupDetails.value.users.push(value as string);
});
addedUsers.value = new Set([]);
removedUsers.value = new Set([]);
addedServiceAccounts.value = new Set([]);
removedServiceAccounts.value = new Set([]);
})
.catch((err) => {
if(err.response.status != 403){
@@ -688,6 +688,61 @@ describe("AddRole Component", () => {
});
});
describe("Start from preset", () => {
it("renders Custom (default) and Read-only radio options", () => {
expect(
wrapper.find('[data-test="add-role-start-from-custom-radio"]').exists(),
).toBe(true);
expect(
wrapper
.find('[data-test="add-role-start-from-readonly-radio"]')
.exists(),
).toBe(true);
// Default selection is "custom".
expect(wrapper.vm.startFrom).toBe("custom");
});
it("emits added:role with role_name and the selected preset", async () => {
vi.mocked(createRole).mockResolvedValue({});
wrapper.vm.name = "readonly_role";
wrapper.vm.startFrom = "readonly";
await wrapper.vm.$nextTick();
await wrapper.vm.saveRole();
await flushPromises();
const added = wrapper.emitted("added:role");
expect(added).toBeTruthy();
expect(added[added.length - 1]).toEqual([
{ role_name: "readonly_role", startFrom: "readonly" },
]);
});
it("defaults the preset back to custom each time the dialog opens", async () => {
wrapper.vm.startFrom = "readonly";
await wrapper.setProps({ open: false });
await wrapper.setProps({ open: true });
expect(wrapper.vm.startFrom).toBe("custom");
});
});
describe("Name error messages", () => {
// NOTE: this spec mocks useI18n so t(key) returns the key verbatim;
// we therefore assert on the distinct keys (required vs invalidChars).
it("shows the required message when the name is empty", async () => {
wrapper.vm.name = "";
await wrapper.vm.$nextTick();
expect(wrapper.vm.nameErrorMessage).toBe("iam.role.name.required");
});
it("shows the invalid-chars message when the name has a space", async () => {
wrapper.vm.name = "bad name";
await wrapper.vm.$nextTick();
expect(wrapper.vm.isValidRoleName).toBe(false);
expect(wrapper.vm.nameErrorMessage).toBe("iam.role.name.invalidChars");
});
});
describe("Analytics", () => {
it.skip("tracks the save role click on successful save", async () => {
vi.mocked(createRole).mockResolvedValue({});
+35 -2
View File
@@ -37,6 +37,21 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:help-text="!showNameError ? t('iam.nameHelpText') : undefined"
@update:model-value="showNameError = !!name && !isValidRoleName"
/>
<div data-test="add-role-start-from-section" class="tw:mt-4">
<div class="tw:mb-1 tw:text-sm tw:font-medium">
{{ t("iam.role.startFrom.label") }}
</div>
<ORadioGroup v-model="startFrom" orientation="vertical">
<ORadio
v-for="option in startFromOptions"
:key="option.value"
:val="option.value"
:label="option.label"
:data-test="`add-role-start-from-${option.value}-radio`"
/>
</ORadioGroup>
</div>
</div>
</ODialog>
</template>
@@ -45,6 +60,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { createRole, updateRole } from "@/services/iam";
import ODialog from "@/lib/overlay/Dialog/ODialog.vue";
import OInput from "@/lib/forms/Input/OInput.vue";
import ORadioGroup from "@/lib/forms/Radio/ORadioGroup.vue";
import ORadio from "@/lib/forms/Radio/ORadio.vue";
import { ref, computed, watch } from "vue";
import { useI18n } from "vue-i18n";
import { useStore } from "vuex";
@@ -73,10 +90,23 @@ const { track } = useReo();
const name = ref(props.role?.name || "");
// "Start from" preset: "custom" = empty role (default), "readonly" = seed
// read-only permissions (AllowList + AllowGet) once the user lands on EditRole.
const startFrom = ref<"custom" | "readonly">("custom");
const startFromOptions = computed(() => [
{ label: t("iam.role.startFrom.custom"), value: "custom" },
{ label: t("iam.role.startFrom.readonly"), value: "readonly" },
]);
const store = useStore();
const isValidRoleName = computed(() => {
// Keep in sync with the backend, which strips everything except
// [a-zA-Z0-9_] via format_role_name_only() (jwt.rs). Hyphens are NOT
// allowed here: the backend would silently rewrite "my-role" → "my_role",
// so we block them in the UI rather than store a name the user didn't type.
const roleNameRegex = /^[a-zA-Z0-9_]+$/;
return roleNameRegex.test(name.value);
});
@@ -88,13 +118,16 @@ watch(
(isOpen) => {
if (isOpen) {
name.value = props.role?.name || "";
startFrom.value = "custom";
showNameError.value = false;
}
}
);
const nameErrorMessage = computed(() =>
!name.value ? t('common.nameRequired') : t('iam.nameHelpText')
!name.value
? t("iam.role.name.required")
: t("iam.role.name.invalidChars")
);
const saveRole = () => {
@@ -102,7 +135,7 @@ const saveRole = () => {
createRole(name.value, store.state.selectedOrganization.identifier)
.then(() => {
emits("update:open", false);
emits("added:role");
emits("added:role", { role_name: name.value, startFrom: startFrom.value });
toast({
message: `Role "${name.value}" Created Successfully!`,
variant: "success",
+93 -1
View File
@@ -15,6 +15,7 @@ vi.mock('@/services/iam', () => ({
bulkDeleteRoles: vi.fn(async () => ({
data: { successful: ['Admin', 'Viewer'], unsuccessful: [] },
})),
getRoleUsers: vi.fn(async () => ({ data: ['user1@o2.ai', 'user2@o2.ai'] })),
}));
vi.mock('@/services/reodotdev_analytics', () => ({
@@ -26,7 +27,7 @@ vi.mock('@/lib/feedback/Toast/useToast', () => ({
}));
import AppRoles from '@/components/iam/roles/AppRoles.vue';
import { getRoles, deleteRole, bulkDeleteRoles } from '@/services/iam';
import { getRoles, deleteRole, bulkDeleteRoles, getRoleUsers } from '@/services/iam';
const node = document.createElement('div');
node.setAttribute('id', 'app-roles-test');
@@ -200,6 +201,50 @@ describe('AppRoles - hideForm', () => {
});
});
// 4b. onRoleAdded — auto-route after create
describe('AppRoles - onRoleAdded', () => {
it('routes to editRole on the permissions tab after create', async () => {
const wrapper = await mountAppRoles();
const spy = vi.spyOn(router, 'push');
(wrapper.vm as any).onRoleAdded({ role_name: 'NewRole' });
expect(spy).toHaveBeenCalledWith(
expect.objectContaining({
name: 'editRole',
params: { role_name: 'NewRole' },
query: expect.objectContaining({ tab: 'permissions' }),
}),
);
});
it('forwards the readonly preset in the query', async () => {
const wrapper = await mountAppRoles();
const spy = vi.spyOn(router, 'push');
(wrapper.vm as any).onRoleAdded({ role_name: 'RO', startFrom: 'readonly' });
expect(spy).toHaveBeenCalledWith(
expect.objectContaining({
query: expect.objectContaining({ preset: 'readonly' }),
}),
);
});
it('does not set a preset for the custom start option', async () => {
const wrapper = await mountAppRoles();
const spy = vi.spyOn(router, 'push');
(wrapper.vm as any).onRoleAdded({ role_name: 'C', startFrom: 'custom' });
const call = spy.mock.calls[0][0] as any;
expect(call.query.preset).toBeUndefined();
});
it('falls back to refreshing the list when no role_name is provided', async () => {
const wrapper = await mountAppRoles();
const spy = vi.spyOn(router, 'push');
vi.mocked(getRoles).mockClear();
(wrapper.vm as any).onRoleAdded({});
expect(spy).not.toHaveBeenCalled();
expect(getRoles).toHaveBeenCalled();
});
});
// 7. showConfirmDialog
describe('AppRoles - showConfirmDialog', () => {
it('sets deleteConformDialog.show to true', async () => {
@@ -214,6 +259,53 @@ describe('AppRoles - showConfirmDialog', () => {
(wrapper.vm as any).showConfirmDialog(row);
expect((wrapper.vm as any).deleteConformDialog.data).toEqual(row);
});
it('builds a blast-radius warning with the live user count', async () => {
const wrapper = await mountAppRoles();
await (wrapper.vm as any).showConfirmDialog({ role_name: 'Admin' });
await flushPromises();
expect(getRoleUsers).toHaveBeenCalledWith(
'Admin',
store.state.selectedOrganization.identifier,
);
// Two mocked users → message mentions "2".
expect((wrapper.vm as any).deleteImpactMessage).toContain('2');
});
it('keeps a generic warning when the user-count lookup fails', async () => {
vi.mocked(getRoleUsers).mockRejectedValueOnce(new Error('boom'));
const wrapper = await mountAppRoles();
await (wrapper.vm as any).showConfirmDialog({ role_name: 'Admin' });
await flushPromises();
// Falls back to the count=0 message rather than throwing.
expect((wrapper.vm as any).deleteImpactMessage).toBeTruthy();
});
});
// bulk-delete blast-radius message
describe('AppRoles - openBulkDeleteDialog', () => {
it('shows the live user count when exactly one role is selected', async () => {
const wrapper = await mountAppRoles();
(wrapper.vm as any).selectedRoleNames = ['Admin'];
await (wrapper.vm as any).openBulkDeleteDialog();
await flushPromises();
expect(getRoleUsers).toHaveBeenCalledWith(
'Admin',
store.state.selectedOrganization.identifier,
);
// Two mocked users → message mentions "2".
expect((wrapper.vm as any).bulkDeleteImpactMessage).toContain('2');
});
it('uses static copy (no count fetch) when multiple roles are selected', async () => {
const wrapper = await mountAppRoles();
vi.mocked(getRoleUsers).mockClear();
(wrapper.vm as any).selectedRoleNames = ['Admin', 'Viewer'];
await (wrapper.vm as any).openBulkDeleteDialog();
await flushPromises();
expect(getRoleUsers).not.toHaveBeenCalled();
expect((wrapper.vm as any).bulkDeleteImpactMessage).toBeTruthy();
});
});
// 8. _deleteRole
+81 -5
View File
@@ -21,9 +21,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<AppPageHeader
:title="t('iam.roles')"
icon="shield"
:subtitle="'Define permissions and access policies'"
class="tw:shrink-0 tw:px-4 tw:border-b tw:border-border-default"
>
<template #subtitle>
<span data-test="iam-roles-subtitle">
{{ t('iam.rolesPage.subtitle') }}
</span>
</template>
<template #actions>
<OButton
data-test="alert-list-add-alert-btn"
@@ -53,11 +57,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</div>
<AddRole
v-model:open="showAddGroup"
@added:role="setupRoles"
@added:role="onRoleAdded"
/>
<ConfirmDialog
title="Delete Role"
:message="`Are you sure you want to delete '${deleteConformDialog?.data?.role_name as string}' role?`"
:warning-message="deleteImpactMessage"
@update:ok="_deleteRole"
@update:cancel="deleteConformDialog.show = false"
v-model="deleteConformDialog.show"
@@ -65,6 +70,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<ConfirmDialog
title="Bulk Delete Roles"
:message="`Are you sure you want to delete ${selectedRoleNames.length} role(s)?`"
:warning-message="bulkDeleteImpactMessage"
@update:ok="bulkDeleteUserRoles"
@update:cancel="confirmBulkDelete = false"
v-model="confirmBulkDelete"
@@ -79,7 +85,7 @@ import AppPageHeader from "@/components/common/AppPageHeader.vue";
import { useI18n } from "vue-i18n";
import RoleTable from "./RoleTable.vue";
import { useRouter } from "vue-router";
import { getRoles, deleteRole, bulkDeleteRoles } from "@/services/iam";
import { getRoles, deleteRole, bulkDeleteRoles, getRoleUsers } from "@/services/iam";
import { useStore } from "vuex";
import usePermissions from "@/composables/iam/usePermissions";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
@@ -136,6 +142,29 @@ const addRole = () => {
showAddGroup.value = true;
};
// After a role is created, route straight into EditRole on the Permissions tab
// so the user can start assigning permissions instead of being dropped back on
// the list with an empty, useless role. The "Read-only" preset is passed
// through so EditRole can seed the starting permissions.
const onRoleAdded = (payload: { role_name: string; startFrom?: string }) => {
if (!payload?.role_name) {
setupRoles();
return;
}
router.push({
name: "editRole",
params: {
role_name: payload.role_name,
},
query: {
org_identifier: store.state.selectedOrganization.identifier,
tab: "permissions",
...(payload.startFrom === "readonly" ? { preset: "readonly" } : {}),
},
});
};
const editRole = (role: any) => {
router.push({
name: "editRole",
@@ -189,9 +218,31 @@ const deleteUserRole = (role: any) => {
});
};
const showConfirmDialog = (row: any) => {
// Blast-radius warning for the single-role delete dialog. We resolve the live
// user count with one getRoleUsers call on delete-click (the list payload has
// no counts), and always warn about bound service accounts via static copy
// since there is no role→service-accounts count endpoint.
const deleteImpactMessage = ref("");
const showConfirmDialog = async (row: any) => {
deleteConformDialog.value.show = true;
deleteConformDialog.value.data = row;
deleteImpactMessage.value = t("iam.rolesPage.delete.impact", { count: 0 });
try {
const res = await getRoleUsers(
row.role_name,
store.state.selectedOrganization.identifier,
);
const userCount = Array.isArray(res.data) ? res.data.length : 0;
deleteImpactMessage.value = t("iam.rolesPage.delete.impact", {
count: userCount,
});
} catch (err) {
// If the count lookup fails, keep the generic static warning rather than
// blocking the delete.
console.log(err);
}
};
const _deleteRole = () => {
@@ -199,8 +250,33 @@ const _deleteRole = () => {
deleteConformDialog.value.data = null;
};
const openBulkDeleteDialog = () => {
// Blast-radius warning for the bulk-delete dialog. With exactly one role
// selected we resolve its live user count (one getRoleUsers call), matching the
// per-row delete. For 2+ roles we keep static copy to avoid N requests.
const bulkDeleteImpactMessage = ref("");
const openBulkDeleteDialog = async () => {
confirmBulkDelete.value = true;
if (selectedRoleNames.value.length === 1) {
bulkDeleteImpactMessage.value = t("iam.rolesPage.delete.impact", {
count: 0,
});
try {
const res = await getRoleUsers(
selectedRoleNames.value[0],
store.state.selectedOrganization.identifier,
);
const userCount = Array.isArray(res.data) ? res.data.length : 0;
bulkDeleteImpactMessage.value = t("iam.rolesPage.delete.impact", {
count: userCount,
});
} catch (err) {
console.log(err);
}
} else {
bulkDeleteImpactMessage.value = t("iam.rolesPage.bulkDelete.impact");
}
};
const bulkDeleteUserRoles = async () => {
@@ -677,3 +677,88 @@ describe('EditRole - modifyResourcePermissions new resource types', () => {
expect(r.permission.AllowPut.show).toBe(true);
});
});
// Default permissions filter for a brand-new (empty) role
describe('EditRole - empty role default filter', () => {
it('defaults the permissions filter to "all" when the role has no permissions', async () => {
// getAllRolePermissions is mocked to return [] (empty role).
const wrapper = await mountEditRole();
expect(wrapper.vm.selectedPermissionsHash.size).toBe(0);
expect(wrapper.vm.filter.permissions).toBe('all');
});
});
// Per-tab dirty state
describe('EditRole - dirty state', () => {
it('is not dirty on initial load', async () => {
const wrapper = await mountEditRole();
expect(wrapper.vm.isAnyDirty).toBe(false);
const permTab = wrapper.vm.tabs.find((t) => t.value === 'permissions');
const usersTab = wrapper.vm.tabs.find((t) => t.value === 'users');
expect(permTab.dirty).toBe(false);
expect(usersTab.dirty).toBe(false);
});
it('marks the permissions tab dirty after a permission change', async () => {
const wrapper = await mountEditRole();
const resource = wrapper.vm.permissionsState.permissions[0];
wrapper.vm.handlePermissionChange(resource, 'AllowGet');
await wrapper.vm.$nextTick();
const permTab = wrapper.vm.tabs.find((t) => t.value === 'permissions');
expect(permTab.dirty).toBe(true);
expect(wrapper.vm.isAnyDirty).toBe(true);
});
it('marks the users tab dirty when a user is staged for addition', async () => {
const wrapper = await mountEditRole();
wrapper.vm.addedUsers.add('new@example.com');
await wrapper.vm.$nextTick();
const usersTab = wrapper.vm.tabs.find((t) => t.value === 'users');
expect(usersTab.dirty).toBe(true);
expect(wrapper.vm.isAnyDirty).toBe(true);
});
it('tracks Users and Service Accounts dirty state independently', async () => {
const wrapper = await mountEditRole();
// Staging a service account must NOT mark the Users tab dirty.
wrapper.vm.addedServiceAccounts.add('svc@example.com');
await wrapper.vm.$nextTick();
const usersTab = wrapper.vm.tabs.find((t) => t.value === 'users');
const saTab = wrapper.vm.tabs.find((t) => t.value === 'serviceAccounts');
expect(saTab?.dirty).toBe(true);
expect(usersTab.dirty).toBe(false);
expect(wrapper.vm.isAnyDirty).toBe(true);
// ...and vice-versa.
wrapper.vm.addedServiceAccounts.clear();
wrapper.vm.addedUsers.add('user@example.com');
await wrapper.vm.$nextTick();
const usersTab2 = wrapper.vm.tabs.find((t) => t.value === 'users');
const saTab2 = wrapper.vm.tabs.find((t) => t.value === 'serviceAccounts');
expect(usersTab2.dirty).toBe(true);
expect(saTab2?.dirty).toBe(false);
});
});
// Read-only preset seeding
describe('EditRole - read-only preset', () => {
it('seeds AllowList + AllowGet on visible top-level resources', async () => {
router.currentRoute.value.query = { preset: 'readonly' };
const wrapper = await mountEditRole();
router.currentRoute.value.query = {};
// At least one resource should now carry pending read-only permissions.
const added = Object.values(wrapper.vm.addedPermissions);
expect(added.length).toBeGreaterThan(0);
const perms = added.map((p) => p.permission);
expect(perms).toContain('AllowGet');
expect(perms.every((p) => p === 'AllowGet' || p === 'AllowList')).toBe(true);
});
it('does not seed permissions without the readonly preset', async () => {
router.currentRoute.value.query = {};
const wrapper = await mountEditRole();
expect(Object.keys(wrapper.vm.addedPermissions).length).toBe(0);
});
});
+118 -6
View File
@@ -32,6 +32,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
data-test="edit-role-tabs"
:tabs="tabs"
:active-tab="activeTab"
:dirty-title="t('iam.editRole.unsavedDot.title')"
@update:active-tab="updateActiveTab"
/>
</div>
@@ -54,8 +55,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
v-show="activeTab === 'serviceAccounts'"
:groupUsers="roleUsers"
:activeTab="activeTab"
:added-users="addedUsers"
:removed-users="removedUsers"
:added-users="addedServiceAccounts"
:removed-users="removedServiceAccounts"
/>
<div
@@ -264,6 +265,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</div>
</div>
<ConfirmDialog
:title="t('iam.editRole.leaveConfirm.title')"
:message="t('iam.editRole.leaveConfirm.message')"
@update:ok="onLeaveConfirm(true)"
@update:cancel="onLeaveConfirm(false)"
v-model="leaveConfirm.show"
/>
</template>
<script setup lang="ts">
@@ -280,8 +288,9 @@ import type { Resource, Entity, Permission } from "@/ts/interfaces";
import PermissionsTable from "@/components/iam/roles/PermissionsTable.vue";
import { useStore } from "vuex";
import usePermissions from "@/composables/iam/usePermissions";
import { useRouter } from "vue-router";
import { useRouter, onBeforeRouteLeave } from "vue-router";
import { onBeforeMount } from "vue";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
import {
updateRole,
getResources,
@@ -368,23 +377,77 @@ const permissionsJsonValue = ref("");
const addedUsers = ref(new Set());
const removedUsers = ref(new Set());
// Service-account membership is staged in its own pair of sets so the Users and
// Service Accounts tabs track dirty state independently (they're sent together
// as users in the save payload, since the backend treats both as principals).
const addedServiceAccounts = ref(new Set());
const removedServiceAccounts = ref(new Set());
const roleUsers: Ref<string[]> = ref([]);
const permissionsUiType = ref("table");
const { getStreams } = useStreams();
// Per-tab unsaved-changes flags. Each tab tracks only its own pending changes.
const isPermissionsDirty = computed(
() =>
Object.keys(addedPermissions.value).length > 0 ||
Object.keys(removedPermissions.value).length > 0,
);
const isUsersDirty = computed(
() => addedUsers.value.size > 0 || removedUsers.value.size > 0,
);
const isServiceAccountsDirty = computed(
() =>
addedServiceAccounts.value.size > 0 ||
removedServiceAccounts.value.size > 0,
);
const isAnyDirty = computed(
() =>
isPermissionsDirty.value ||
isUsersDirty.value ||
isServiceAccountsDirty.value,
);
// Route-leave guard: warn before discarding unsaved permission/membership
// changes. The pending navigation is held until the user resolves the dialog.
const leaveConfirm = ref<{
show: boolean;
resolve: ((proceed: boolean) => void) | null;
}>({ show: false, resolve: null });
const onLeaveConfirm = (proceed: boolean) => {
leaveConfirm.value.show = false;
leaveConfirm.value.resolve?.(proceed);
leaveConfirm.value.resolve = null;
};
onBeforeRouteLeave(() => {
if (!isAnyDirty.value) return true;
return new Promise<boolean>((resolve) => {
leaveConfirm.value.resolve = resolve;
leaveConfirm.value.show = true;
});
});
const tabs = computed(() => {
const baseTabs = [
{
value: "permissions",
label: "Permissions",
icon: "shield",
dirty: isPermissionsDirty.value,
},
{
value: "users",
label: "Users",
icon: "group",
dirty: isUsersDirty.value,
},
];
@@ -393,6 +456,7 @@ const tabs = computed(() => {
value: "serviceAccounts",
label: "Service Accounts",
icon: "smart-toy",
dirty: isServiceAccountsDirty.value,
});
}
@@ -469,6 +533,21 @@ const getRoleDetails = () => {
await updateRolePermissions(permissions.value);
isFetchingInitialRoles.value = false;
// A brand-new role has no saved permissions, so the default "Selected"
// filter renders an empty matrix that looks broken. Default to "All" so
// the user sees the full permission grid to start checking boxes.
if (selectedPermissionsHash.value.size === 0) {
filter.value.permissions = "all";
// "Read-only" preset (from the Add Role dialog) seeds AllowList +
// AllowGet across all top-level resources so evaluators get a safe,
// non-empty starting point. These land as pending "added" permissions
// the user can still tweak before saving.
if (router.currentRoute.value.query.preset === "readonly") {
seedReadonlyPreset();
}
}
updateTableData();
})
.catch((error) => {
@@ -869,6 +948,23 @@ const cancelPermissionsUpdate = () => {
);
};
// Seed AllowList + AllowGet on every visible top-level resource. Mirrors a
// user manually checking those two columns, so the changes flow through the
// normal added/removed-permission bookkeeping and the Save payload.
const seedReadonlyPreset = () => {
const readonlyPerms = ["AllowList", "AllowGet"];
permissionsState.permissions.forEach((resource: Resource) => {
readonlyPerms.forEach((perm) => {
const permDetail = resource.permission?.[perm as "AllowList"];
// Only seed permissions the resource actually exposes and that are not
// already selected.
if (!permDetail || !permDetail.show || permDetail.value) return;
permDetail.value = true;
handlePermissionChange(resource, perm);
});
});
};
const handlePermissionChange = (row: any, permission: string) => {
let entity = "";
let resourceName = row.resourceName;
@@ -2294,11 +2390,17 @@ const updateResourceResource = (
const saveRole = () => {
if (permissionsUiType.value === "json") updateJsonInTable();
// Users and service accounts are both sent as users; merge the two staging
// sets (dedup via Set) for the request payload.
const payload = {
add: Object.values(addedPermissions.value),
remove: Object.values(removedPermissions.value),
add_users: Array.from(addedUsers.value) as string[],
remove_users: Array.from(removedUsers.value) as string[],
add_users: Array.from(
new Set([...addedUsers.value, ...addedServiceAccounts.value]),
) as string[],
remove_users: Array.from(
new Set([...removedUsers.value, ...removedServiceAccounts.value]),
) as string[],
};
if (
@@ -2352,16 +2454,26 @@ const saveRole = () => {
removedPermissions.value = {};
roleUsers.value = roleUsers.value.filter(
(user) => !removedUsers.value.has(user),
(user) =>
!removedUsers.value.has(user) &&
!removedServiceAccounts.value.has(user),
);
addedUsers.value.forEach((value: any) => {
roleUsers.value.push(value);
});
addedServiceAccounts.value.forEach((value: any) => {
roleUsers.value.push(value);
});
addedUsers.value = new Set([]);
removedUsers.value = new Set([]);
addedServiceAccounts.value = new Set([]);
removedServiceAccounts.value = new Set([]);
})
.catch((err) => {
if (err.response.status != 403) {
@@ -312,22 +312,34 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<span class="tw:text-xs tw:text-text-secondary">{{ tokenNextStepHint }}</span>
</div>
<div v-if="showGroupLink" class="tw:flex tw:items-center tw:gap-4 tw:mt-3">
<div v-if="showGroupLink" class="tw:flex tw:flex-wrap tw:items-center tw:justify-center tw:gap-3 tw:mt-3">
<router-link
data-test="service-accounts-list-token-add-to-role"
:to="roleLinkTarget"
class="tw:text-xs tw:text-primary tw:font-medium"
class="tw:group tw:inline-flex tw:items-center tw:gap-1.5 tw:rounded-md tw:border tw:border-border-default tw:px-2.5 tw:py-1.5 tw:text-xs tw:text-text-primary tw:transition-colors tw:hover:border-primary tw:hover:bg-primary/5"
@click="isShowToken = false"
>
{{ t('serviceAccounts.tokenReveal.addToRole') }} →
<OIcon name="shield" size="sm" class="tw:text-primary tw:shrink-0" />
<span class="tw:font-medium">{{ t('serviceAccounts.tokenReveal.addToRole') }}</span>
<OIcon
name="arrow-right"
size="sm"
class="tw:text-text-secondary tw:shrink-0 tw:transition-transform tw:group-hover:translate-x-0.5"
/>
</router-link>
<router-link
data-test="service-accounts-list-token-add-to-group"
:to="groupLinkTarget"
class="tw:text-xs tw:text-primary tw:font-medium"
class="tw:group tw:inline-flex tw:items-center tw:gap-1.5 tw:rounded-md tw:border tw:border-border-default tw:px-2.5 tw:py-1.5 tw:text-xs tw:text-text-primary tw:transition-colors tw:hover:border-primary tw:hover:bg-primary/5"
@click="isShowToken = false"
>
{{ t('serviceAccounts.tokenReveal.addToGroup') }} →
<OIcon name="group" size="sm" class="tw:text-primary tw:shrink-0" />
<span class="tw:font-medium">{{ t('serviceAccounts.tokenReveal.addToGroup') }}</span>
<OIcon
name="arrow-right"
size="sm"
class="tw:text-text-secondary tw:shrink-0 tw:transition-transform tw:group-hover:translate-x-0.5"
/>
</router-link>
</div>
+62 -4
View File
@@ -1320,10 +1320,10 @@
"env": "Environment Variable",
"nextStep": "Use the token in your API requests. Store it securely; it will not be shown again.",
"nextStepCloud": "Use the token in your API requests. Contact your admin for the API endpoint.",
"nextStepGrant": "This account has no permissions yet. Add it to a group to grant access.",
"nextStepGrant": "This service account has no permissions yet. Assign it a role, or add it to a user group, to grant access.",
"nextStepOss": "Grant access from the Roles & Groups page.",
"addToGroup": "Add to Group",
"addToRole": "Add to Role",
"addToGroup": "Add to a user group",
"addToRole": "Assign a role",
"step1Title": "Copy your token",
"step2Title": "Grant permissions",
"copyHint": "Copy this token now — you won't be able to see it again.",
@@ -1402,7 +1402,60 @@
"invitations": "Invitations",
"quota": "Quotas",
"externalUserWarningTitle": "External user warning",
"externalUserWarningMessage": "Adding external users directly to roles won't grant permissions. Add them to User Groups instead."
"externalUserWarningMessage": "Role changes for SSO/external users apply on their next login, in line with their identity-provider claims, and may be overridden by them. To grant lasting access, add them to a User Group mapped from your IdP instead.",
"role": {
"startFrom": {
"label": "Start from",
"custom": "Custom (empty role)",
"readonly": "Read-only (view access to all resources)"
},
"name": {
"required": "Role name is required.",
"invalidChars": "Use letters, numbers and underscores only, without spaces."
}
},
"editRole": {
"unsavedDot": {
"title": "This tab has unsaved changes."
},
"leaveConfirm": {
"title": "Discard unsaved changes?",
"message": "You have unsaved permission or member changes. Leaving this page will discard them."
}
},
"rolesPage": {
"subtitle": "Roles bundle permissions — assign them to Users, Service Accounts, or Groups to grant access.",
"delete": {
"impact": "{count} user(s) and any service accounts bound to this role will lose access."
},
"bulkDelete": {
"impact": "Any users and service accounts bound to these roles will lose access."
}
},
"group": {
"name": {
"required": "Group name is required.",
"invalidChars": "Use letters, numbers and underscores only, without spaces."
}
},
"editGroup": {
"unsavedDot": {
"title": "This tab has unsaved changes."
},
"leaveConfirm": {
"title": "Discard unsaved changes?",
"message": "You have unsaved role or member changes. Leaving this page will discard them."
}
},
"groupsPage": {
"subtitle": "User Groups bundle roles — add members to a group to grant them all its roles at once.",
"delete": {
"impact": "{count} member(s) will lose the roles granted by this group."
},
"bulkDelete": {
"impact": "Members of these groups will lose the roles granted by them."
}
}
},
"ticket": {
"header": "Support Tickets",
@@ -1929,6 +1982,11 @@
"deleteTokenBtn": "Delete",
"copyTokenBtn": "Copy",
"copyTokenWarning": "Copy this token now. It will not be shown again.",
"tokenAuthHeader": "Authorization",
"authHeaderLabel": "Authorization header (ready to use)",
"authHeaderHelp": "Paste this into the Authorization header of your ingestion request — no manual encoding needed.",
"copyAuthHeaderBtn": "Copy Authorization header",
"copyRawTokenBtn": "Copy raw token",
"newTokenRevealed": "New Token Generated",
"deleteTokenConfirm": "Delete token \"{name}\"? Pipelines using this token will stop working.",
"rotateTokenConfirm": "Rotate token \"{name}\"? All pipelines using this token must be updated with the new value.",