mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 08:09:27 +00:00
chore: IAM roles & group ux fixes (#12958)
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: "",
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"]),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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({});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user