diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 2c231d5..52702d6 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -6,7 +6,7 @@ on:
tags:
- 'v*'
pull_request:
- branches: [ main ]
+ branches: [ main, 'release/v*', 'dev-v*' ]
env:
NODE_VERSION: 22.x
diff --git a/RELEASE.md b/RELEASE.md
index 17c0d00..3b91da8 100644
--- a/RELEASE.md
+++ b/RELEASE.md
@@ -1,13 +1,9 @@
-> v0.1.11 ~ "RELEASE_NOTES_PLACEHOLDER — replace this line with the release title"
+> v0.1.12 ~ "IAM dialogs open from anywhere"
---
## Highlights
-RELEASE_NOTES_PLACEHOLDER
-
-Describe what changed in this release. The first line above must name the version
-being released, and both placeholder markers must be gone, or the release workflow
-refuses to tag.
+- **IAM dialogs are reusable services.** New `user-actions`, `group-actions`, `role-actions` and `policy-actions` services hold the create, edit, invite and permission dialogs that lived in the index controllers, so other engines can open them — Fleetbase AI uses this to open **New User** from its confirmation cards. The IAM pages delegate to the same services, so buttons, row actions and deep links behave as before. ([#33](https://github.com/fleetbase/iam-engine/pull/33))
---
## Need help?
diff --git a/addon/components/modals/invite-user.hbs b/addon/components/modals/invite-user.hbs
index 95dfd14..ab46124 100644
--- a/addon/components/modals/invite-user.hbs
+++ b/addon/components/modals/invite-user.hbs
@@ -6,7 +6,7 @@
-
+
-
+
+ {{else if (get @row @column.contactPath)}}
+
+ {{else}}
+ -
+ {{/if}}
+{{/let}}
diff --git a/addon/components/table/cell/verification-status.js b/addon/components/table/cell/verification-status.js
new file mode 100644
index 0000000..73fdc57
--- /dev/null
+++ b/addon/components/table/cell/verification-status.js
@@ -0,0 +1,8 @@
+import Component from '@glimmer/component';
+
+/**
+ * Table cell showing whether a user's email or phone is verified. The column's
+ * `valuePath` is the verified-at date and `contactPath` the email/phone it
+ * belongs to, so a user without that contact shows a dash instead.
+ */
+export default class TableCellVerificationStatusComponent extends Component {}
diff --git a/addon/controllers/application.js b/addon/controllers/application.js
index 6605b55..361216b 100644
--- a/addon/controllers/application.js
+++ b/addon/controllers/application.js
@@ -19,41 +19,12 @@ export default class ApplicationController extends Controller {
},
{
label: this.intl.t('iam.common.user'),
- description: 'Manage identities and account types.',
+ description: 'Manage team member accounts.',
icon: 'id-card',
route: 'console.iam.users',
permission: 'iam list user',
visible: this.can('iam see user'),
- keywords: ['identity', 'accounts', 'members'],
- children: [
- {
- label: 'Users',
- description: 'All console users.',
- icon: 'user',
- route: 'console.iam.users.index',
- permission: 'iam list user',
- visible: this.can('iam see user'),
- keywords: ['people', 'members'],
- },
- {
- label: 'Drivers',
- description: 'Driver accounts.',
- icon: 'id-card',
- route: 'console.iam.users.drivers',
- permission: 'iam list user',
- visible: this.can('iam see user'),
- keywords: ['fleet operators'],
- },
- {
- label: 'Customers',
- description: 'Customer accounts.',
- icon: 'users',
- route: 'console.iam.users.customers',
- permission: 'iam list user',
- visible: this.can('iam see user'),
- keywords: ['clients'],
- },
- ],
+ keywords: ['identity', 'accounts', 'members', 'people', 'team'],
},
{
label: this.intl.t('iam.common.group'),
diff --git a/addon/controllers/groups/index.js b/addon/controllers/groups/index.js
index a3a472e..a79b287 100644
--- a/addon/controllers/groups/index.js
+++ b/addon/controllers/groups/index.js
@@ -4,9 +4,9 @@ import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { isBlank } from '@ember/utils';
import { timeout, task } from 'ember-concurrency';
-import getWithDefault from '@fleetbase/ember-core/utils/get-with-default';
export default class GroupsIndexController extends Controller {
+ @service groupActions;
@controller('users.index') usersIndexController;
@service store;
@service intl;
@@ -170,107 +170,17 @@ export default class GroupsIndexController extends Controller {
this.crud.export('group');
}
- /**
- * Toggles modal to create a new group
- *
- * @void
- */
- @action createGroup() {
- const formPermission = 'iam create group';
- const group = this.store.createRecord('group', { users: [] });
-
- this.editGroup(group, {
- title: this.intl.t('iam.groups.index.new-group'),
- acceptButtonText: this.intl.t('common.confirm'),
- acceptButtonIcon: 'check',
- acceptButtonDisabled: this.abilities.cannot(formPermission),
- acceptButtonHelpText: this.abilities.cannot(formPermission) ? this.intl.t('common.unauthorized') : null,
- formPermission,
- group,
- confirm: async (modal) => {
- modal.startLoading();
-
- if (this.abilities.cannot(formPermission)) {
- return this.notifications.warning(this.intl.t('common.permissions-required-for-changes'));
- }
- try {
- await group.save();
- this.notifications.success(this.intl.t('iam.groups.index.new-group-created'));
- return this.hostRouter.refresh();
- } catch (error) {
- this.notifications.serverError(error);
- modal.stopLoading();
- }
- },
- });
+ // Dialog and lifecycle actions live in the group-actions service so other engines can open them too.
+ @action createGroup(...args) {
+ return this.groupActions.createGroup(...args);
}
- /**
- * Toggles modal to edit a group
- *
- * @void
- */
- @action editGroup(group, options = {}) {
- const formPermission = 'iam update group';
- this.modalsManager.show('modals/group-form', {
- title: this.intl.t('iam.groups.index.edit-group-title'),
- acceptButtonText: this.intl.t('common.save-changes'),
- acceptButtonIcon: 'save',
- acceptButtonDisabled: this.abilities.cannot(formPermission),
- acceptButtonHelpText: this.abilities.cannot(formPermission) ? this.intl.t('common.unauthorized') : null,
- formPermission,
- group,
- lastSelectedUser: null,
- removeUser: (user) => {
- group.users.removeObject(user);
- },
- addUser: (user) => {
- group.users.pushObject(user);
- this.modalsManager.setOption('lastSelectedUser', null);
- },
- confirm: async (modal) => {
- modal.startLoading();
-
- if (this.abilities.cannot(formPermission)) {
- return this.notifications.warning(this.intl.t('common.permissions-required-for-changes'));
- }
-
- try {
- await group.save();
- this.notifications.success(this.intl.t('iam.groups.index.changes-group-save'));
- return this.hostRouter.refresh();
- } catch (error) {
- this.notifications.serverError(error);
- modal.stopLoading();
- }
- },
- ...options,
- });
+ @action editGroup(...args) {
+ return this.groupActions.editGroup(...args);
}
- /**
- * Toggles dialog to delete a group
- *
- * @void
- */
- @action deleteGroup(group) {
- const groupName = getWithDefault(group, 'name', this.intl.t('iam.groups.index.untitled'));
-
- this.modalsManager.confirm({
- title: this.intl.t('iam.groups.index.delete-group-title', { groupName }),
- body: this.intl.t('iam.groups.index.data-assosciated-this-group-deleted'),
- confirm: async (modal) => {
- modal.startLoading();
- try {
- await group.destroyRecord();
- this.notifications.success(this.intl.t('iam.groups.index.delete-group-success-message', { name: group.name }));
- return this.hostRouter.refresh();
- } catch (error) {
- this.notifications.serverError(error);
- modal.stopLoading();
- }
- },
- });
+ @action deleteGroup(...args) {
+ return this.groupActions.deleteGroup(...args);
}
@action async openDeepLinkedResource() {
diff --git a/addon/controllers/policies/index.js b/addon/controllers/policies/index.js
index 5c2cd1e..4c79c90 100644
--- a/addon/controllers/policies/index.js
+++ b/addon/controllers/policies/index.js
@@ -6,6 +6,7 @@ import { isBlank } from '@ember/utils';
import { timeout, task } from 'ember-concurrency';
export default class PoliciesIndexController extends Controller {
+ @service policyActions;
@service store;
@service intl;
@service notifications;
@@ -198,128 +199,21 @@ export default class PoliciesIndexController extends Controller {
});
}
- /**
- * Toggles modal to create a new API key
- *
- * @void
- */
- @action createPolicy() {
- const formPermission = 'iam create policy';
- const policy = this.store.createRecord('policy', {
- is_mutable: true,
- is_deletable: true,
- });
-
- this.editPolicy(policy, {
- title: this.intl.t('iam.policies.index.new-policy'),
- acceptButtonText: this.intl.t('common.confirm'),
- acceptButtonIcon: 'check',
- acceptButtonDisabled: this.abilities.cannot(formPermission),
- acceptButtonHelpText: this.abilities.cannot(formPermission) ? this.intl.t('common.unauthorized') : null,
- formPermission,
- confirm: async (modal) => {
- modal.startLoading();
-
- if (this.abilities.cannot(formPermission)) {
- return this.notifications.warning(this.intl.t('common.permissions-required-for-changes'));
- }
-
- try {
- await policy.save();
- this.notifications.success(this.intl.t('iam.policies.index.new-policy-created'));
- return this.hostRouter.refresh();
- } catch (error) {
- this.notifications.serverError(error);
- modal.stopLoading();
- }
- },
- });
+ // Dialog and lifecycle actions live in the policy-actions service so other engines can open them too.
+ @action createPolicy(...args) {
+ return this.policyActions.createPolicy(...args);
}
- /**
- * Toggles modal to create a new API key
- *
- * @param {PolicyModel} policy
- * @memberof PoliciesIndexController
- * @void
- */
- @action editPolicy(policy, options = {}) {
- if (!policy.is_mutable) {
- return this.viewPolicyPermissions(policy, options);
- }
-
- const formPermission = 'iam update policy';
- this.modalsManager.show('modals/policy-form', {
- title: this.intl.t('iam.policies.index.edit-policy-title'),
- acceptButtonText: this.intl.t('common.save-changes'),
- acceptButtonIcon: 'save',
- acceptButtonDisabled: this.abilities.cannot(formPermission),
- acceptButtonHelpText: this.abilities.cannot(formPermission) ? this.intl.t('common.unauthorized') : null,
- formPermission,
- policy,
- confirm: async (modal) => {
- modal.startLoading();
-
- if (this.abilities.cannot(formPermission)) {
- return this.notifications.warning(this.intl.t('common.permissions-required-for-changes'));
- }
-
- try {
- await policy.save();
- this.notifications.success(this.intl.t('iam.policies.index.changes-policy-saved-success'));
- return this.hostRouter.refresh();
- } catch (error) {
- this.notifications.serverError(error);
- modal.stopLoading();
- }
- },
- ...options,
- });
+ @action editPolicy(...args) {
+ return this.policyActions.editPolicy(...args);
}
- /**
- * View policy permissions
- *
- * @param {PolicyModel} policy
- * @memberof PoliciesIndexController
- */
- @action viewPolicyPermissions(policy, options = {}) {
- this.modalsManager.show('modals/view-policy-permissions', {
- title: this.intl.t('iam.components.modals.view-policy-permissions.view-permissions', { policyName: policy.name }),
- hideDeclineButton: true,
- acceptButtonText: this.intl.t('common.done'),
- policy,
- ...options,
- });
+ @action viewPolicyPermissions(...args) {
+ return this.policyActions.viewPolicyPermissions(...args);
}
- /**
- * Toggles dialog to delete API key
- *
- * @param {PolicyModel} policy
- * @memberof PoliciesIndexController
- * @void
- */
- @action deletePolicy(policy) {
- if (!policy.is_deletable) {
- return this.notifications.warning(this.intl.t('iam.policies.index.unable-delete-policy-warning', { policyType: policy.type }));
- }
-
- this.modalsManager.confirm({
- title: `Delete (${policy.name || 'Untitled'}) policy`,
- body: this.intl.t('iam.policies.index.data-assosciated-this-policy-deleted'),
- confirm: async (modal) => {
- modal.startLoading();
- try {
- await policy.destroyRecord();
- this.notifications.success(this.intl.t('iam.policies.index.policy-deleted', { policyName: policy.name }));
- return this.hostRouter.refresh();
- } catch (error) {
- this.notifications.serverError(error);
- modal.stopLoading();
- }
- },
- });
+ @action deletePolicy(...args) {
+ return this.policyActions.deletePolicy(...args);
}
/**
diff --git a/addon/controllers/roles/index.js b/addon/controllers/roles/index.js
index 3aea299..9c8980a 100644
--- a/addon/controllers/roles/index.js
+++ b/addon/controllers/roles/index.js
@@ -6,6 +6,7 @@ import { isBlank } from '@ember/utils';
import { timeout, task } from 'ember-concurrency';
export default class RolesIndexController extends Controller {
+ @service roleActions;
@service store;
@service intl;
@service notifications;
@@ -198,152 +199,21 @@ export default class RolesIndexController extends Controller {
});
}
- /**
- * Toggles dialog to create a new Role
- *
- * @void
- */
- @action createRole() {
- const formPermission = 'iam create role';
- const role = this.store.createRecord('role', { is_mutable: true });
-
- this.editRole(role, {
- title: this.intl.t('iam.roles.index.new-role'),
- acceptButtonText: this.intl.t('common.confirm'),
- acceptButtonIcon: 'check',
- acceptButtonDisabled: this.abilities.cannot(formPermission),
- acceptButtonHelpText: this.abilities.cannot(formPermission) ? this.intl.t('common.unauthorized') : null,
- formPermission,
- keepOpen: true,
- confirm: async (modal) => {
- modal.startLoading();
-
- if (!role.name || typeof role.name !== 'string') {
- modal.stopLoading();
- return this.notifications.warning('Role name is required.');
- }
-
- const roleName = role.name.toLowerCase();
- if (roleName === 'administrator' || roleName.startsWith('admin')) {
- modal.stopLoading();
- return this.notifications.error('Creating a role with name "Administrator" or a role name that starts with "Admin" is prohibited, as the name is system reserved.');
- }
-
- if (this.abilities.cannot(formPermission)) {
- modal.stopLoading();
- return this.notifications.warning(this.intl.t('common.permissions-required-for-changes'));
- }
-
- try {
- await role.save();
- this.notifications.success(this.intl.t('iam.roles.index.new-role-create'));
- modal.done();
- return this.hostRouter.refresh();
- } catch (error) {
- this.notifications.serverError(error);
- modal.stopLoading();
- }
- },
- });
+ // Dialog and lifecycle actions live in the role-actions service so other engines can open them too.
+ @action createRole(...args) {
+ return this.roleActions.createRole(...args);
}
- /**
- * Toggles dialog to edit a Role
- *
- * @void
- */
- @action editRole(role, options = {}) {
- if (!role.is_mutable) {
- return this.viewRolePermissions(role, options);
- }
-
- const formPermission = 'iam update role';
- this.modalsManager.show('modals/role-form', {
- title: this.intl.t('iam.roles.index.edit-role-title'),
- acceptButtonText: this.intl.t('common.save-changes'),
- acceptButtonIcon: 'save',
- acceptButtonDisabled: this.abilities.cannot(formPermission),
- acceptButtonHelpText: this.abilities.cannot(formPermission) ? this.intl.t('common.unauthorized') : null,
- keepOpen: true,
- formPermission,
- role,
- setPermissions: (permissions) => {
- role.permissions = permissions;
- },
- confirm: async (modal) => {
- modal.startLoading();
-
- if (!role.name || typeof role.name !== 'string') {
- modal.stopLoading();
- return this.notifications.warning('Role name is required.');
- }
-
- const roleName = role.name.toLowerCase();
- if (roleName === 'administrator' || roleName.startsWith('admin')) {
- modal.stopLoading();
- return this.notifications.error('Creating a role with name "Administrator" or a role name that starts with "Admin" is prohibited, as the name is system reserved.');
- }
-
- if (this.abilities.cannot(formPermission)) {
- modal.stopLoading();
- return this.notifications.warning(this.intl.t('common.permissions-required-for-changes'));
- }
-
- try {
- await role.save();
- this.notifications.success(this.intl.t('iam.roles.index.changes-role-saved'));
- modal.done();
- return this.hostRouter.refresh();
- } catch (error) {
- this.notifications.serverError(error);
- modal.stopLoading();
- }
- },
- ...options,
- });
+ @action editRole(...args) {
+ return this.roleActions.editRole(...args);
}
- /**
- * Toggles dialog to delete Role
- *
- * @void
- */
- @action deleteRole(role) {
- if (!role.is_deletable) {
- return this.notifications.warning(this.intl.t('iam.roles.index.unable-delete-role-warning', { roleType: role.type }));
- }
-
- this.modalsManager.confirm({
- title: `Delete (${role.name || 'Untitled'}) role`,
- body: this.intl.t('iam.roles.index.data-assosciated-this-role-deleted'),
- confirm: async (modal) => {
- modal.startLoading();
- try {
- await role.destroyRecord();
- this.notifications.success(this.intl.t('iam.roles.index.role-deleted', { roleName: role.name }));
- return this.hostRouter.refresh();
- } catch (error) {
- this.notifications.serverError(error);
- modal.stopLoading();
- }
- },
- });
+ @action deleteRole(...args) {
+ return this.roleActions.deleteRole(...args);
}
- /**
- * View role permissions
- *
- * @param {RoleModel} role
- * @memberof RolesIndexController
- */
- @action viewRolePermissions(role, options = {}) {
- this.modalsManager.show('modals/view-role-permissions', {
- title: this.intl.t('iam.components.modals.view-role-permissions.view-permissions', { roleName: role.name }),
- hideDeclineButton: true,
- acceptButtonText: this.intl.t('common.done'),
- role,
- ...options,
- });
+ @action viewRolePermissions(...args) {
+ return this.roleActions.viewRolePermissions(...args);
}
/**
diff --git a/addon/controllers/users.js b/addon/controllers/users.js
index e4d2877..a883b13 100644
--- a/addon/controllers/users.js
+++ b/addon/controllers/users.js
@@ -5,23 +5,6 @@ import { getOwner } from '@ember/application';
export default class UsersController extends Controller {
@service hostRouter;
- get tabs() {
- return [
- {
- route: 'users.index',
- label: 'Users',
- },
- {
- route: 'users.drivers',
- label: 'Drivers',
- },
- {
- route: 'users.customers',
- label: 'Customers',
- },
- ];
- }
-
get childController() {
const owner = getOwner(this);
const fullRouteName = this.hostRouter.currentRouteName;
diff --git a/addon/controllers/users/customers.js b/addon/controllers/users/customers.js
deleted file mode 100644
index e46e5d6..0000000
--- a/addon/controllers/users/customers.js
+++ /dev/null
@@ -1,3 +0,0 @@
-import UsersIndexController from './index';
-
-export default class UsersCustomersController extends UsersIndexController {}
diff --git a/addon/controllers/users/drivers.js b/addon/controllers/users/drivers.js
deleted file mode 100644
index 70f1afa..0000000
--- a/addon/controllers/users/drivers.js
+++ /dev/null
@@ -1,3 +0,0 @@
-import UsersIndexController from './index';
-
-export default class UsersDriversController extends UsersIndexController {}
diff --git a/addon/controllers/users/index.js b/addon/controllers/users/index.js
index 26391d0..00ad0b0 100644
--- a/addon/controllers/users/index.js
+++ b/addon/controllers/users/index.js
@@ -6,6 +6,7 @@ import { isBlank } from '@ember/utils';
import { timeout, task } from 'ember-concurrency';
export default class UsersIndexController extends Controller {
+ @service userActions;
@service store;
@service intl;
@service notifications;
@@ -64,7 +65,25 @@ export default class UsersIndexController extends Controller {
];
}
- queryParams = ['view_user', 'page', 'limit', 'sort', 'query', 'type', 'created_by', 'updated_by', 'status', 'role', 'name', 'phone', 'email'];
+ queryParams = [
+ 'view_user',
+ 'page',
+ 'limit',
+ 'sort',
+ 'query',
+ 'type',
+ 'created_by',
+ 'updated_by',
+ 'status',
+ 'role',
+ 'name',
+ 'phone',
+ 'email',
+ 'email_verified',
+ 'phone_verified',
+ 'country',
+ 'timezone',
+ ];
@tracked page = 1;
@tracked limit;
@tracked query;
@@ -74,6 +93,10 @@ export default class UsersIndexController extends Controller {
@tracked phone;
@tracked email;
@tracked role;
+ @tracked email_verified;
+ @tracked phone_verified;
+ @tracked country;
+ @tracked timezone;
@tracked sort = '-created_at';
/**
@@ -134,6 +157,66 @@ export default class UsersIndexController extends Controller {
filterParam: 'status',
filterOptions: ['pending', 'active', 'inactive'],
},
+ {
+ label: this.intl.t('iam.users.index.email-verified'),
+ valuePath: 'email_verified_at',
+ contactPath: 'email',
+ cellComponent: 'table/cell/verification-status',
+ sortable: false,
+ filterable: true,
+ filterComponent: 'filter/select',
+ filterParam: 'email_verified',
+ filterOptions: this.verificationFilterOptions,
+ },
+ {
+ label: this.intl.t('iam.users.index.phone-verified'),
+ valuePath: 'phone_verified_at',
+ contactPath: 'phone',
+ cellComponent: 'table/cell/verification-status',
+ sortable: false,
+ filterable: true,
+ filterComponent: 'filter/select',
+ filterParam: 'phone_verified',
+ filterOptions: this.verificationFilterOptions,
+ },
+ {
+ label: this.intl.t('iam.common.country'),
+ valuePath: 'country',
+ cellComponent: 'table/cell/country',
+ hidden: true,
+ resizable: true,
+ sortable: true,
+ filterable: true,
+ filterComponent: 'filter/country',
+ filterParam: 'country',
+ },
+ {
+ label: this.intl.t('iam.users.index.timezone'),
+ valuePath: 'timezone',
+ hidden: true,
+ resizable: true,
+ sortable: true,
+ filterable: true,
+ filterComponent: 'filter/string',
+ filterParam: 'timezone',
+ },
+ {
+ label: this.intl.t('iam.users.index.date-of-birth'),
+ valuePath: 'date_of_birth',
+ hidden: true,
+ resizable: true,
+ sortable: true,
+ filterable: false,
+ },
+ {
+ label: this.intl.t('iam.users.index.ip-address'),
+ valuePath: 'ip_address',
+ cellComponent: 'click-to-copy',
+ hidden: true,
+ resizable: true,
+ sortable: false,
+ filterable: false,
+ },
{
label: this.intl.t('iam.users.index.last-login'),
valuePath: 'lastLogin',
@@ -204,11 +287,30 @@ export default class UsersIndexController extends Controller {
isVisible: (user) => user.get('session_status') === 'inactive' || (this.currentUser.user.is_admin && user.get('session_status') === 'pending'),
},
{
- label: this.intl.t('iam.users.index.verify-user'),
- fn: this.verifyUser,
+ label: this.intl.t('iam.users.index.send-email-verification'),
+ fn: (user) => this.userActions.sendVerification(user, 'email'),
+ permission: 'iam verify user',
+ isVisible: (user) => this.canVerify(user, 'email'),
+ },
+ {
+ label: this.intl.t('iam.users.index.send-phone-verification'),
+ fn: (user) => this.userActions.sendVerification(user, 'phone'),
+ permission: 'iam verify user',
+ isVisible: (user) => this.canVerify(user, 'phone'),
+ },
+ {
+ label: this.intl.t('iam.users.index.mark-email-verified'),
+ fn: (user) => this.userActions.markVerified(user, 'email'),
className: 'text-danger',
permission: 'iam verify user',
- isVisible: (user) => !user.get('email_verified_at'),
+ isVisible: (user) => this.canVerify(user, 'email'),
+ },
+ {
+ label: this.intl.t('iam.users.index.mark-phone-verified'),
+ fn: (user) => this.userActions.markVerified(user, 'phone'),
+ className: 'text-danger',
+ permission: 'iam verify user',
+ isVisible: (user) => this.canVerify(user, 'phone'),
},
{
label: this.intl.t('iam.users.index.change-user-password'),
@@ -236,6 +338,29 @@ export default class UsersIndexController extends Controller {
},
];
+ /**
+ * Options for the email/phone verified column filters.
+ *
+ * @var {Array}
+ */
+ get verificationFilterOptions() {
+ return [
+ { label: this.intl.t('iam.users.index.verified'), value: 'true' },
+ { label: this.intl.t('iam.users.index.unverified'), value: 'false' },
+ ];
+ }
+
+ /**
+ * Whether the user's email or phone can be verified: it is set and not yet verified.
+ *
+ * @param {UserModel} user
+ * @param {String} channel `email` or `phone`
+ * @return {Boolean}
+ */
+ canVerify(user, channel) {
+ return Boolean(user.get(channel)) && !user.get(`${channel}_verified_at`);
+ }
+
/**
* The search task.
*
@@ -288,328 +413,49 @@ export default class UsersIndexController extends Controller {
this.crud.export('users', { params: { selections } });
}
- /**
- * View user permissions.
- *
- * @param {UserModel} user
- * @memberof UsersIndexController
- */
- @action viewUserPermissions(user) {
- this.modalsManager.show('modals/view-user-permissions', {
- title: this.intl.t('iam.components.modals.view-user-permissions.view-permissions', { userName: user.name }),
- hideDeclineButton: true,
- acceptButtonText: this.intl.t('common.done'),
- user,
- });
+ // Dialog and lifecycle actions live in the user-actions service so other engines can open them too.
+ @action viewUserPermissions(...args) {
+ return this.userActions.viewUserPermissions(...args);
}
- /**
- * Opens the Invite User dialog.
- *
- * Sends only an email (and optional name / role) to POST users/invite-user.
- * The backend handles both cases transparently:
- * - Email already in the system → cross-organisation invite issued.
- * - Brand-new email → pending user created and invite email sent.
- *
- * The response includes `invited: true` when an existing user was invited,
- * allowing the frontend to display the appropriate success message.
- *
- * @void
- */
- @action inviteUser() {
- this.modalsManager.show('modals/invite-user', {
- title: this.intl.t('iam.users.invite.title'),
- acceptButtonText: this.intl.t('iam.users.invite.send-invitation'),
- acceptButtonIcon: 'paper-plane',
- email: '',
- name: '',
- role: null,
- confirm: async (modal) => {
- modal.startLoading();
-
- const email = modal.getOption('email');
- const name = modal.getOption('name');
- const role = modal.getOption('role');
-
- if (!email) {
- this.notifications.warning(this.intl.t('iam.users.invite.email-required'));
- return modal.stopLoading();
- }
-
- try {
- const response = await this.fetch.post('users/invite-user', {
- user: {
- email,
- name,
- role_uuid: role ? role.id : undefined,
- },
- });
-
- const wasExistingUser = response && response.invited === true;
- this.notifications.success(wasExistingUser ? this.intl.t('iam.users.invite.invitation-sent-existing') : this.intl.t('iam.users.invite.invitation-sent-new'));
-
- modal.done();
- return this.hostRouter.refresh();
- } catch (error) {
- this.notifications.serverError(error);
- modal.stopLoading();
- }
- },
- });
+ @action inviteUser(...args) {
+ return this.userActions.inviteUser(...args);
}
- /**
- * Toggles modal to create a new API key
- *
- * @void
- */
- @action createUser() {
- const formPermission = 'iam create user';
- const user = this.store.createRecord('user', {
- status: 'pending',
- type: 'user',
- });
-
- this.editUser(user, {
- title: this.intl.t('iam.users.index.new-user'),
- acceptButtonText: this.intl.t('common.confirm'),
- acceptButtonIcon: 'check',
- acceptButtonDisabled: this.abilities.cannot(formPermission),
- acceptButtonHelpText: this.abilities.cannot(formPermission) ? this.intl.t('common.unauthorized') : null,
- formPermission,
- allowEmailEdit: true,
- confirm: async (modal) => {
- modal.startLoading();
-
- if (this.abilities.cannot(formPermission)) {
- return this.notifications.warning(this.intl.t('common.permissions-required-for-changes'));
- }
-
- try {
- await user.save();
- this.notifications.success(this.intl.t('iam.users.index.new-user-created'));
- this.hostRouter.refresh();
- modal.done();
- } catch (error) {
- this.notifications.serverError(error);
- modal.stopLoading();
- }
- },
- });
+ @action createUser(...args) {
+ return this.userActions.createUser(...args);
}
- /**
- * Toggles modal to create a new API key
- *
- * @void
- */
- @action editUser(user, options = {}) {
- const formPermission = 'iam update user';
- this.modalsManager.show('modals/user-form', {
- title: this.intl.t('iam.users.index.edit-user-title'),
- modalClass: 'modal-lg',
- acceptButtonText: this.intl.t('common.save-changes'),
- acceptButtonIcon: 'save',
- acceptButtonDisabled: this.abilities.cannot(formPermission),
- acceptButtonHelpText: this.abilities.cannot(formPermission) ? this.intl.t('common.unauthorized') : null,
- keepOpen: true,
- formPermission,
- allowEmailEdit: false,
- user,
- uploadNewPhoto: (file) => {
- this.fetch.uploadFile.perform(
- file,
- {
- path: `uploads/${user.company_uuid}/users/${user.slug}`,
- key_uuid: user.id,
- key_type: `user`,
- type: `user_photo`,
- },
- (uploadedFile) => {
- user.setProperties({
- avatar_uuid: uploadedFile.id,
- avatar_url: uploadedFile.url,
- avatar: uploadedFile,
- });
- }
- );
- },
- confirm: async (modal) => {
- modal.startLoading();
-
- if (this.abilities.cannot(formPermission)) {
- return this.notifications.warning(this.intl.t('common.permissions-required-for-changes'));
- }
-
- try {
- await user.save();
- this.notifications.success(this.intl.t('iam.users.index.user-changes-saved-success'));
- this.hostRouter.refresh();
- modal.done();
- } catch (error) {
- this.notifications.serverError(error);
-
- // If error is because email address was made empty rollback changes
- if (error && typeof error.message === 'string' && error.message.includes('Email address cannot be empty')) {
- user.rollbackAttributes();
- }
-
- modal.stopLoading();
- }
- },
- ...options,
- });
+ @action editUser(...args) {
+ return this.userActions.editUser(...args);
}
- /**
- * Toggles dialog to delete API key
- *
- * @void
- */
- @action deleteUser(user) {
- if (user.id === this.currentUser.id) {
- return this.notifications.error(this.intl.t('iam.users.index.error-you-cant-delete-yourself'));
- }
-
- this.modalsManager.confirm({
- title: this.intl.t('iam.users.index.delete-user-title', { userName: user.get('name') }),
- body: this.intl.t('iam.users.index.data-assosciated-user-delete'),
- confirm: async (modal) => {
- modal.startLoading();
-
- try {
- await user.removeFromCurrentCompany();
- this.notifications.success(this.intl.t('iam.users.index.delete-user-success-message', { userName: user.get('name') }));
- this.hostRouter.refresh();
- } catch (error) {
- this.notifications.serverError(error);
- modal.stopLoading();
- }
- },
- });
+ @action deleteUser(...args) {
+ return this.userActions.deleteUser(...args);
}
- /**
- * Deactivates a user
- *
- * @void
- */
- @action deactivateUser(user) {
- this.modalsManager.confirm({
- title: this.intl.t('iam.users.index.deactivate-user-title', { userName: user.get('name') }),
- body: this.intl.t('iam.users.index.access-account-or-resources-unless-re-activated'),
- confirm: async (modal) => {
- modal.startLoading();
-
- try {
- await user.deactivate();
- this.notifications.success(this.intl.t('iam.users.index.deactivate-user-success-message', { userName: user.get('name') }));
- this.hostRouter.refresh();
- } catch (error) {
- this.notifications.serverError(error);
- modal.stopLoading();
- }
- },
- });
+ @action deactivateUser(...args) {
+ return this.userActions.deactivateUser(...args);
}
- /**
- * Activate a user
- *
- * @void
- */
- @action activateUser(user) {
- this.modalsManager.confirm({
- title: this.intl.t('iam.users.index.re-activate-user-title', { userName: user.get('name') }),
- body: this.intl.t('iam.users.index.this-user-will-regain-access-to-your-organization'),
- confirm: async (modal) => {
- modal.startLoading();
-
- try {
- await user.activate();
- this.notifications.success(this.intl.t('iam.users.index.re-activate-user-success-message', { userName: user.get('name') }));
- this.hostRouter.refresh();
- } catch (error) {
- this.notifications.serverError(error);
- modal.stopLoading();
- }
- },
- });
+ @action activateUser(...args) {
+ return this.userActions.activateUser(...args);
}
- /**
- * Verify a user
- *
- * @void
- */
- @action verifyUser(user) {
- this.modalsManager.confirm({
- title: this.intl.t('iam.users.index.verify-user-title', { userName: user.get('name') }),
- body: this.intl.t('iam.users.index.verify-user-manually-prompt'),
- confirm: async (modal) => {
- modal.startLoading();
-
- try {
- await user.verify();
- this.notifications.success(this.intl.t('iam.users.index.user-verified-success-message', { userName: user.get('name') }));
- this.hostRouter.refresh();
- } catch (error) {
- this.notifications.serverError(error);
- modal.stopLoading();
- }
- },
- });
+ @action verifyUser(...args) {
+ return this.userActions.verifyUser(...args);
}
- /**
- * Change password for a user
- *
- * @void
- */
- @action changeUserPassword(user) {
- this.modalsManager.show('modals/change-user-password', {
- keepOpen: true,
- user,
- });
+ @action changeUserPassword(...args) {
+ return this.userActions.changeUserPassword(...args);
}
- /**
- * Change email for a user
- *
- * @void
- */
- @action changeUserEmail(user) {
- this.modalsManager.show('modals/change-user-email', {
- keepOpen: true,
- user,
- onEmailChangeComplete: () => {
- return this.hostRouter.refresh();
- },
- });
+ @action changeUserEmail(...args) {
+ return this.userActions.changeUserEmail(...args);
}
- /**
- * Resends invite for a user to join.
- *
- * @void
- */
- @action resendInvitation(user) {
- this.modalsManager.confirm({
- title: this.intl.t('iam.users.index.resend-invitation-to-join-organization'),
- body: this.intl.t('iam.users.index.confirming-fleetbase-will-re-send-invitation-for-user-to-join-your-organization'),
- confirm: async (modal) => {
- modal.startLoading();
-
- try {
- await user.resendInvite();
- this.notifications.success(this.intl.t('iam.users.index.invitation-resent'));
- this.hostRouter.refresh();
- } catch (error) {
- this.notifications.serverError(error);
- modal.stopLoading();
- }
- },
- });
+ @action resendInvitation(...args) {
+ return this.userActions.resendInvitation(...args);
}
/**
diff --git a/addon/extension.js b/addon/extension.js
index 57a8366..d89149b 100644
--- a/addon/extension.js
+++ b/addon/extension.js
@@ -159,18 +159,6 @@ export default {
icon: 'user',
route: 'console.iam.users',
},
- {
- title: 'Drivers',
- description: 'View and manage driver accounts linked to your organisation.',
- icon: 'id-card',
- route: 'console.iam.users.drivers',
- },
- {
- title: 'Customers',
- description: 'View and manage customer accounts linked to your organisation.',
- icon: 'users',
- route: 'console.iam.users.customers',
- },
{
title: 'Groups',
description: 'Organise users into groups for bulk permission management.',
diff --git a/addon/routes.js b/addon/routes.js
index 2b1b75c..d5f58c3 100644
--- a/addon/routes.js
+++ b/addon/routes.js
@@ -4,8 +4,6 @@ export default buildRoutes(function () {
this.route('home', { path: '/' }, function () {});
this.route('users', function () {
this.route('index', { path: '/' });
- this.route('drivers');
- this.route('customers');
});
this.route('groups', function () {});
this.route('roles', function () {});
diff --git a/addon/routes/users/customers.js b/addon/routes/users/customers.js
deleted file mode 100644
index 4287644..0000000
--- a/addon/routes/users/customers.js
+++ /dev/null
@@ -1,22 +0,0 @@
-import Route from '@ember/routing/route';
-import { inject as service } from '@ember/service';
-
-export default class UsersCustomersRoute extends Route {
- @service store;
-
- queryParams = {
- page: { refreshModel: true },
- limit: { refreshModel: true },
- sort: { refreshModel: true },
- query: { refreshModel: true },
- status: { refreshModel: true },
- role: { refreshModel: true },
- name: { refreshModel: true },
- phone: { refreshModel: true },
- email: { refreshModel: true },
- };
-
- model(params) {
- return this.store.query('user', { ...params, is_customer: 1 });
- }
-}
diff --git a/addon/routes/users/drivers.js b/addon/routes/users/drivers.js
deleted file mode 100644
index fba268f..0000000
--- a/addon/routes/users/drivers.js
+++ /dev/null
@@ -1,22 +0,0 @@
-import Route from '@ember/routing/route';
-import { inject as service } from '@ember/service';
-
-export default class UsersDriversRoute extends Route {
- @service store;
-
- queryParams = {
- page: { refreshModel: true },
- limit: { refreshModel: true },
- sort: { refreshModel: true },
- query: { refreshModel: true },
- status: { refreshModel: true },
- role: { refreshModel: true },
- name: { refreshModel: true },
- phone: { refreshModel: true },
- email: { refreshModel: true },
- };
-
- model(params) {
- return this.store.query('user', { ...params, is_driver: 1 });
- }
-}
diff --git a/addon/services/group-actions.js b/addon/services/group-actions.js
new file mode 100644
index 0000000..c2ce88e
--- /dev/null
+++ b/addon/services/group-actions.js
@@ -0,0 +1,126 @@
+import ResourceActionService from '@fleetbase/ember-core/services/resource-action';
+import { action } from '@ember/object';
+import getWithDefault from '@fleetbase/ember-core/utils/get-with-default';
+
+/**
+ * Group actions shared by the IAM pages and other engines (for example Fleetbase AI), so the
+ * same dialogs open from anywhere in the console.
+ */
+export default class GroupActionsService extends ResourceActionService {
+ constructor() {
+ super(...arguments);
+ this.initialize('group', { permissionPrefix: 'iam', mountPrefix: 'console.iam' });
+ }
+
+ transition = {
+ list: () => this.transitionTo('groups'),
+ };
+
+ modal = {
+ create: (...args) => this.createGroup(...args),
+ edit: (...args) => this.editGroup(...args),
+ };
+
+ /**
+ * Toggles modal to create a new group
+ *
+ * @void
+ */
+ @action createGroup() {
+ const formPermission = 'iam create group';
+ const group = this.store.createRecord('group', { users: [] });
+
+ this.editGroup(group, {
+ title: this.intl.t('iam.groups.index.new-group'),
+ acceptButtonText: this.intl.t('common.confirm'),
+ acceptButtonIcon: 'check',
+ acceptButtonDisabled: this.abilities.cannot(formPermission),
+ acceptButtonHelpText: this.abilities.cannot(formPermission) ? this.intl.t('common.unauthorized') : null,
+ formPermission,
+ group,
+ confirm: async (modal) => {
+ modal.startLoading();
+
+ if (this.abilities.cannot(formPermission)) {
+ return this.notifications.warning(this.intl.t('common.permissions-required-for-changes'));
+ }
+ try {
+ await group.save();
+ this.notifications.success(this.intl.t('iam.groups.index.new-group-created'));
+ return this.hostRouter.refresh();
+ } catch (error) {
+ this.notifications.serverError(error);
+ modal.stopLoading();
+ }
+ },
+ });
+ }
+
+ /**
+ * Toggles modal to edit a group
+ *
+ * @void
+ */
+ @action editGroup(group, options = {}) {
+ const formPermission = 'iam update group';
+ this.modalsManager.show('modals/group-form', {
+ title: this.intl.t('iam.groups.index.edit-group-title'),
+ acceptButtonText: this.intl.t('common.save-changes'),
+ acceptButtonIcon: 'save',
+ acceptButtonDisabled: this.abilities.cannot(formPermission),
+ acceptButtonHelpText: this.abilities.cannot(formPermission) ? this.intl.t('common.unauthorized') : null,
+ formPermission,
+ group,
+ lastSelectedUser: null,
+ removeUser: (user) => {
+ group.users.removeObject(user);
+ },
+ addUser: (user) => {
+ group.users.pushObject(user);
+ this.modalsManager.setOption('lastSelectedUser', null);
+ },
+ confirm: async (modal) => {
+ modal.startLoading();
+
+ if (this.abilities.cannot(formPermission)) {
+ return this.notifications.warning(this.intl.t('common.permissions-required-for-changes'));
+ }
+
+ try {
+ await group.save();
+ this.notifications.success(this.intl.t('iam.groups.index.changes-group-save'));
+ return this.hostRouter.refresh();
+ } catch (error) {
+ this.notifications.serverError(error);
+ modal.stopLoading();
+ }
+ },
+ ...options,
+ });
+ }
+
+ /**
+ * Toggles dialog to delete a group
+ *
+ * @void
+ */
+ @action deleteGroup(group) {
+ const groupName = getWithDefault(group, 'name', this.intl.t('iam.groups.index.untitled'));
+
+ this.modalsManager.confirm({
+ title: this.intl.t('iam.groups.index.delete-group-title', { groupName }),
+ body: this.intl.t('iam.groups.index.data-assosciated-this-group-deleted'),
+ confirm: async (modal) => {
+ modal.startLoading();
+ try {
+ await group.destroyRecord();
+ this.notifications.success(this.intl.t('iam.groups.index.delete-group-success-message', { name: group.name }));
+ return this.hostRouter.refresh();
+ } catch (error) {
+ this.notifications.serverError(error);
+ modal.stopLoading();
+ }
+ },
+ });
+ }
+}
diff --git a/addon/services/policy-actions.js b/addon/services/policy-actions.js
new file mode 100644
index 0000000..830a154
--- /dev/null
+++ b/addon/services/policy-actions.js
@@ -0,0 +1,147 @@
+import ResourceActionService from '@fleetbase/ember-core/services/resource-action';
+import { action } from '@ember/object';
+
+/**
+ * Policy actions shared by the IAM pages and other engines (for example Fleetbase AI), so the
+ * same dialogs open from anywhere in the console.
+ */
+export default class PolicyActionsService extends ResourceActionService {
+ constructor() {
+ super(...arguments);
+ this.initialize('policy', { permissionPrefix: 'iam', mountPrefix: 'console.iam' });
+ }
+
+ transition = {
+ list: () => this.transitionTo('policies'),
+ };
+
+ modal = {
+ create: (...args) => this.createPolicy(...args),
+ edit: (...args) => this.editPolicy(...args),
+ viewPermissions: (...args) => this.viewPolicyPermissions(...args),
+ };
+
+ /**
+ * Opens the dialog for this resource
+ *
+ * @void
+ */
+ @action createPolicy() {
+ const formPermission = 'iam create policy';
+ const policy = this.store.createRecord('policy', {
+ is_mutable: true,
+ is_deletable: true,
+ });
+
+ this.editPolicy(policy, {
+ title: this.intl.t('iam.policies.index.new-policy'),
+ acceptButtonText: this.intl.t('common.confirm'),
+ acceptButtonIcon: 'check',
+ acceptButtonDisabled: this.abilities.cannot(formPermission),
+ acceptButtonHelpText: this.abilities.cannot(formPermission) ? this.intl.t('common.unauthorized') : null,
+ formPermission,
+ confirm: async (modal) => {
+ modal.startLoading();
+
+ if (this.abilities.cannot(formPermission)) {
+ return this.notifications.warning(this.intl.t('common.permissions-required-for-changes'));
+ }
+
+ try {
+ await policy.save();
+ this.notifications.success(this.intl.t('iam.policies.index.new-policy-created'));
+ return this.hostRouter.refresh();
+ } catch (error) {
+ this.notifications.serverError(error);
+ modal.stopLoading();
+ }
+ },
+ });
+ }
+
+ /**
+ * Opens the dialog for this resource
+ *
+ * @param {PolicyModel} policy
+ * @memberof PolicyActionsService
+ * @void
+ */
+ @action editPolicy(policy, options = {}) {
+ if (!policy.is_mutable) {
+ return this.viewPolicyPermissions(policy, options);
+ }
+
+ const formPermission = 'iam update policy';
+ this.modalsManager.show('modals/policy-form', {
+ title: this.intl.t('iam.policies.index.edit-policy-title'),
+ acceptButtonText: this.intl.t('common.save-changes'),
+ acceptButtonIcon: 'save',
+ acceptButtonDisabled: this.abilities.cannot(formPermission),
+ acceptButtonHelpText: this.abilities.cannot(formPermission) ? this.intl.t('common.unauthorized') : null,
+ formPermission,
+ policy,
+ confirm: async (modal) => {
+ modal.startLoading();
+
+ if (this.abilities.cannot(formPermission)) {
+ return this.notifications.warning(this.intl.t('common.permissions-required-for-changes'));
+ }
+
+ try {
+ await policy.save();
+ this.notifications.success(this.intl.t('iam.policies.index.changes-policy-saved-success'));
+ return this.hostRouter.refresh();
+ } catch (error) {
+ this.notifications.serverError(error);
+ modal.stopLoading();
+ }
+ },
+ ...options,
+ });
+ }
+
+ /**
+ * View policy permissions
+ *
+ * @param {PolicyModel} policy
+ * @memberof PolicyActionsService
+ */
+ @action viewPolicyPermissions(policy, options = {}) {
+ this.modalsManager.show('modals/view-policy-permissions', {
+ title: this.intl.t('iam.components.modals.view-policy-permissions.view-permissions', { policyName: policy.name }),
+ hideDeclineButton: true,
+ acceptButtonText: this.intl.t('common.done'),
+ policy,
+ ...options,
+ });
+ }
+
+ /**
+ * Confirms and deletes the resource
+ *
+ * @param {PolicyModel} policy
+ * @memberof PolicyActionsService
+ * @void
+ */
+ @action deletePolicy(policy) {
+ if (!policy.is_deletable) {
+ return this.notifications.warning(this.intl.t('iam.policies.index.unable-delete-policy-warning', { policyType: policy.type }));
+ }
+
+ this.modalsManager.confirm({
+ title: `Delete (${policy.name || 'Untitled'}) policy`,
+ body: this.intl.t('iam.policies.index.data-assosciated-this-policy-deleted'),
+ confirm: async (modal) => {
+ modal.startLoading();
+ try {
+ await policy.destroyRecord();
+ this.notifications.success(this.intl.t('iam.policies.index.policy-deleted', { policyName: policy.name }));
+ return this.hostRouter.refresh();
+ } catch (error) {
+ this.notifications.serverError(error);
+ modal.stopLoading();
+ }
+ },
+ });
+ }
+}
diff --git a/addon/services/role-actions.js b/addon/services/role-actions.js
new file mode 100644
index 0000000..ce234af
--- /dev/null
+++ b/addon/services/role-actions.js
@@ -0,0 +1,171 @@
+import ResourceActionService from '@fleetbase/ember-core/services/resource-action';
+import { action } from '@ember/object';
+
+/**
+ * Role actions shared by the IAM pages and other engines (for example Fleetbase AI), so the
+ * same dialogs open from anywhere in the console.
+ */
+export default class RoleActionsService extends ResourceActionService {
+ constructor() {
+ super(...arguments);
+ this.initialize('role', { permissionPrefix: 'iam', mountPrefix: 'console.iam' });
+ }
+
+ transition = {
+ list: () => this.transitionTo('roles'),
+ };
+
+ modal = {
+ create: (...args) => this.createRole(...args),
+ edit: (...args) => this.editRole(...args),
+ viewPermissions: (...args) => this.viewRolePermissions(...args),
+ };
+
+ /**
+ * Toggles dialog to create a new Role
+ *
+ * @void
+ */
+ @action createRole() {
+ const formPermission = 'iam create role';
+ const role = this.store.createRecord('role', { is_mutable: true });
+
+ this.editRole(role, {
+ title: this.intl.t('iam.roles.index.new-role'),
+ acceptButtonText: this.intl.t('common.confirm'),
+ acceptButtonIcon: 'check',
+ acceptButtonDisabled: this.abilities.cannot(formPermission),
+ acceptButtonHelpText: this.abilities.cannot(formPermission) ? this.intl.t('common.unauthorized') : null,
+ formPermission,
+ keepOpen: true,
+ confirm: async (modal) => {
+ modal.startLoading();
+
+ if (!role.name || typeof role.name !== 'string') {
+ modal.stopLoading();
+ return this.notifications.warning('Role name is required.');
+ }
+
+ const roleName = role.name.toLowerCase();
+ if (roleName === 'administrator' || roleName.startsWith('admin')) {
+ modal.stopLoading();
+ return this.notifications.error('Creating a role with name "Administrator" or a role name that starts with "Admin" is prohibited, as the name is system reserved.');
+ }
+
+ if (this.abilities.cannot(formPermission)) {
+ modal.stopLoading();
+ return this.notifications.warning(this.intl.t('common.permissions-required-for-changes'));
+ }
+
+ try {
+ await role.save();
+ this.notifications.success(this.intl.t('iam.roles.index.new-role-create'));
+ modal.done();
+ return this.hostRouter.refresh();
+ } catch (error) {
+ this.notifications.serverError(error);
+ modal.stopLoading();
+ }
+ },
+ });
+ }
+
+ /**
+ * Toggles dialog to edit a Role
+ *
+ * @void
+ */
+ @action editRole(role, options = {}) {
+ if (!role.is_mutable) {
+ return this.viewRolePermissions(role, options);
+ }
+
+ const formPermission = 'iam update role';
+ this.modalsManager.show('modals/role-form', {
+ title: this.intl.t('iam.roles.index.edit-role-title'),
+ acceptButtonText: this.intl.t('common.save-changes'),
+ acceptButtonIcon: 'save',
+ acceptButtonDisabled: this.abilities.cannot(formPermission),
+ acceptButtonHelpText: this.abilities.cannot(formPermission) ? this.intl.t('common.unauthorized') : null,
+ keepOpen: true,
+ formPermission,
+ role,
+ setPermissions: (permissions) => {
+ role.permissions = permissions;
+ },
+ confirm: async (modal) => {
+ modal.startLoading();
+
+ if (!role.name || typeof role.name !== 'string') {
+ modal.stopLoading();
+ return this.notifications.warning('Role name is required.');
+ }
+
+ const roleName = role.name.toLowerCase();
+ if (roleName === 'administrator' || roleName.startsWith('admin')) {
+ modal.stopLoading();
+ return this.notifications.error('Creating a role with name "Administrator" or a role name that starts with "Admin" is prohibited, as the name is system reserved.');
+ }
+
+ if (this.abilities.cannot(formPermission)) {
+ modal.stopLoading();
+ return this.notifications.warning(this.intl.t('common.permissions-required-for-changes'));
+ }
+
+ try {
+ await role.save();
+ this.notifications.success(this.intl.t('iam.roles.index.changes-role-saved'));
+ modal.done();
+ return this.hostRouter.refresh();
+ } catch (error) {
+ this.notifications.serverError(error);
+ modal.stopLoading();
+ }
+ },
+ ...options,
+ });
+ }
+
+ /**
+ * Toggles dialog to delete Role
+ *
+ * @void
+ */
+ @action deleteRole(role) {
+ if (!role.is_deletable) {
+ return this.notifications.warning(this.intl.t('iam.roles.index.unable-delete-role-warning', { roleType: role.type }));
+ }
+
+ this.modalsManager.confirm({
+ title: `Delete (${role.name || 'Untitled'}) role`,
+ body: this.intl.t('iam.roles.index.data-assosciated-this-role-deleted'),
+ confirm: async (modal) => {
+ modal.startLoading();
+ try {
+ await role.destroyRecord();
+ this.notifications.success(this.intl.t('iam.roles.index.role-deleted', { roleName: role.name }));
+ return this.hostRouter.refresh();
+ } catch (error) {
+ this.notifications.serverError(error);
+ modal.stopLoading();
+ }
+ },
+ });
+ }
+
+ /**
+ * View role permissions
+ *
+ * @param {RoleModel} role
+ * @memberof RoleActionsService
+ */
+ @action viewRolePermissions(role, options = {}) {
+ this.modalsManager.show('modals/view-role-permissions', {
+ title: this.intl.t('iam.components.modals.view-role-permissions.view-permissions', { roleName: role.name }),
+ hideDeclineButton: true,
+ acceptButtonText: this.intl.t('common.done'),
+ role,
+ ...options,
+ });
+ }
+}
diff --git a/addon/services/user-actions.js b/addon/services/user-actions.js
new file mode 100644
index 0000000..0008eff
--- /dev/null
+++ b/addon/services/user-actions.js
@@ -0,0 +1,439 @@
+import ResourceActionService from '@fleetbase/ember-core/services/resource-action';
+import { action } from '@ember/object';
+
+/**
+ * User actions shared by the IAM pages and other engines (for example Fleetbase AI), so the
+ * same dialogs open from anywhere in the console.
+ */
+export default class UserActionsService extends ResourceActionService {
+ constructor() {
+ super(...arguments);
+ this.initialize('user', { permissionPrefix: 'iam', mountPrefix: 'console.iam' });
+ }
+
+ transition = {
+ list: () => this.transitionTo('users.index'),
+ };
+
+ modal = {
+ create: (...args) => this.createUser(...args),
+ edit: (...args) => this.editUser(...args),
+ invite: (...args) => this.inviteUser(...args),
+ viewPermissions: (...args) => this.viewUserPermissions(...args),
+ changePassword: (...args) => this.changeUserPassword(...args),
+ changeEmail: (...args) => this.changeUserEmail(...args),
+ };
+
+ /**
+ * View user permissions.
+ *
+ * @param {UserModel} user
+ * @memberof UserActionsService
+ */
+ @action viewUserPermissions(user) {
+ this.modalsManager.show('modals/view-user-permissions', {
+ title: this.intl.t('iam.components.modals.view-user-permissions.view-permissions', { userName: user.name }),
+ hideDeclineButton: true,
+ acceptButtonText: this.intl.t('common.done'),
+ user,
+ });
+ }
+
+ /**
+ * Opens the Invite User dialog.
+ *
+ * Sends only an email (and optional name / role) to POST users/invite-user.
+ * The backend handles both cases transparently:
+ * - Email already in the system → cross-organisation invite issued.
+ * - Brand-new email → pending user created and invite email sent.
+ *
+ * The response includes `invited: true` when an existing user was invited,
+ * allowing the frontend to display the appropriate success message.
+ *
+ * @void
+ */
+ @action inviteUser() {
+ this.modalsManager.show('modals/invite-user', {
+ title: this.intl.t('iam.users.invite.title'),
+ acceptButtonText: this.intl.t('iam.users.invite.send-invitation'),
+ acceptButtonIcon: 'paper-plane',
+ email: '',
+ name: '',
+ role: null,
+ confirm: async (modal) => {
+ modal.startLoading();
+
+ const email = modal.getOption('email');
+ const name = modal.getOption('name');
+ const role = modal.getOption('role');
+
+ if (!email) {
+ this.notifications.warning(this.intl.t('iam.users.invite.email-required'));
+ return modal.stopLoading();
+ }
+
+ // A role is always chosen explicitly: no access is granted by default
+ if (!role) {
+ this.notifications.warning(this.intl.t('iam.users.role-required'));
+ return modal.stopLoading();
+ }
+
+ try {
+ const response = await this.fetch.post('users/invite-user', {
+ user: {
+ email,
+ name,
+ role_uuid: role ? role.id : undefined,
+ },
+ });
+
+ const promotedFrom = response?.promoted_from;
+ if (promotedFrom) {
+ this.notifyPromotedAccount(promotedFrom, response?.user?.name ?? name ?? email);
+ } else {
+ const wasExistingUser = response?.invited === true;
+ this.notifications.success(wasExistingUser ? this.intl.t('iam.users.invite.invitation-sent-existing') : this.intl.t('iam.users.invite.invitation-sent-new'));
+ }
+
+ modal.done();
+ return this.hostRouter.refresh();
+ } catch (error) {
+ this.notifications.serverError(error);
+ modal.stopLoading();
+ }
+ },
+ });
+ }
+
+ /**
+ * Shows the toast for a managed driver/customer/contact account that the backend
+ * upgraded to a team member instead of creating a new user.
+ *
+ * @param {String} type the account's previous type (driver, customer or contact)
+ * @param {String} name
+ * @void
+ */
+ notifyPromotedAccount(type, name) {
+ this.notifications.success(this.intl.t('iam.users.notifications.promoted-existing-account', { type, name: name ?? '' }));
+ }
+
+ /**
+ * Opens the dialog for this resource
+ *
+ * @void
+ */
+ @action createUser() {
+ const formPermission = 'iam create user';
+ const user = this.store.createRecord('user', {
+ status: 'pending',
+ type: 'user',
+ });
+
+ this.editUser(user, {
+ title: this.intl.t('iam.users.index.new-user'),
+ acceptButtonText: this.intl.t('common.confirm'),
+ acceptButtonIcon: 'check',
+ acceptButtonDisabled: this.abilities.cannot(formPermission),
+ acceptButtonHelpText: this.abilities.cannot(formPermission) ? this.intl.t('common.unauthorized') : null,
+ formPermission,
+ allowEmailEdit: true,
+ confirm: async (modal) => {
+ modal.startLoading();
+
+ if (this.abilities.cannot(formPermission)) {
+ return this.notifications.warning(this.intl.t('common.permissions-required-for-changes'));
+ }
+
+ // A role is always chosen explicitly: no access is granted by default
+ if (!user.get('role.id')) {
+ this.notifications.warning(this.intl.t('iam.users.role-required'));
+ return modal.stopLoading();
+ }
+
+ try {
+ await user.save();
+
+ // When the email/phone belonged to a driver/customer/contact account in this
+ // organisation, the backend upgrades that account instead of creating a duplicate.
+ const promotedFrom = user.promoted_from ?? user.meta?.promoted_from;
+ if (promotedFrom) {
+ this.notifyPromotedAccount(promotedFrom, user.name ?? user.email);
+ } else {
+ this.notifications.success(this.intl.t('iam.users.index.new-user-created'));
+ }
+ this.hostRouter.refresh();
+ modal.done();
+ } catch (error) {
+ this.notifications.serverError(error);
+ modal.stopLoading();
+ }
+ },
+ });
+ }
+
+ /**
+ * Opens the dialog for this resource
+ *
+ * @void
+ */
+ @action editUser(user, options = {}) {
+ const formPermission = 'iam update user';
+ this.modalsManager.show('modals/user-form', {
+ title: this.intl.t('iam.users.index.edit-user-title'),
+ modalClass: 'modal-lg',
+ acceptButtonText: this.intl.t('common.save-changes'),
+ acceptButtonIcon: 'save',
+ acceptButtonDisabled: this.abilities.cannot(formPermission),
+ acceptButtonHelpText: this.abilities.cannot(formPermission) ? this.intl.t('common.unauthorized') : null,
+ keepOpen: true,
+ formPermission,
+ allowEmailEdit: false,
+ user,
+ uploadNewPhoto: (file) => {
+ this.fetch.uploadFile.perform(
+ file,
+ {
+ path: `uploads/${user.company_uuid}/users/${user.slug}`,
+ key_uuid: user.id,
+ key_type: `user`,
+ type: `user_photo`,
+ },
+ (uploadedFile) => {
+ user.setProperties({
+ avatar_uuid: uploadedFile.id,
+ avatar_url: uploadedFile.url,
+ avatar: uploadedFile,
+ });
+ }
+ );
+ },
+ confirm: async (modal) => {
+ modal.startLoading();
+
+ if (this.abilities.cannot(formPermission)) {
+ return this.notifications.warning(this.intl.t('common.permissions-required-for-changes'));
+ }
+
+ try {
+ await user.save();
+ this.notifications.success(this.intl.t('iam.users.index.user-changes-saved-success'));
+ this.hostRouter.refresh();
+ modal.done();
+ } catch (error) {
+ this.notifications.serverError(error);
+
+ // If error is because email address was made empty rollback changes
+ if (error && typeof error.message === 'string' && error.message.includes('Email address cannot be empty')) {
+ user.rollbackAttributes();
+ }
+
+ modal.stopLoading();
+ }
+ },
+ ...options,
+ });
+ }
+
+ /**
+ * Confirms and deletes the resource
+ *
+ * @void
+ */
+ @action deleteUser(user) {
+ if (user.id === this.currentUser.id) {
+ return this.notifications.error(this.intl.t('iam.users.index.error-you-cant-delete-yourself'));
+ }
+
+ this.modalsManager.confirm({
+ title: this.intl.t('iam.users.index.delete-user-title', { userName: user.get('name') }),
+ body: this.intl.t('iam.users.index.data-assosciated-user-delete'),
+ confirm: async (modal) => {
+ modal.startLoading();
+
+ try {
+ await user.removeFromCurrentCompany();
+ this.notifications.success(this.intl.t('iam.users.index.delete-user-success-message', { userName: user.get('name') }));
+ this.hostRouter.refresh();
+ } catch (error) {
+ this.notifications.serverError(error);
+ modal.stopLoading();
+ }
+ },
+ });
+ }
+
+ /**
+ * Deactivates a user
+ *
+ * @void
+ */
+ @action deactivateUser(user) {
+ this.modalsManager.confirm({
+ title: this.intl.t('iam.users.index.deactivate-user-title', { userName: user.get('name') }),
+ body: this.intl.t('iam.users.index.access-account-or-resources-unless-re-activated'),
+ confirm: async (modal) => {
+ modal.startLoading();
+
+ try {
+ await user.deactivate();
+ this.notifications.success(this.intl.t('iam.users.index.deactivate-user-success-message', { userName: user.get('name') }));
+ this.hostRouter.refresh();
+ } catch (error) {
+ this.notifications.serverError(error);
+ modal.stopLoading();
+ }
+ },
+ });
+ }
+
+ /**
+ * Activate a user
+ *
+ * @void
+ */
+ @action activateUser(user) {
+ this.modalsManager.confirm({
+ title: this.intl.t('iam.users.index.re-activate-user-title', { userName: user.get('name') }),
+ body: this.intl.t('iam.users.index.this-user-will-regain-access-to-your-organization'),
+ confirm: async (modal) => {
+ modal.startLoading();
+
+ try {
+ await user.activate();
+ this.notifications.success(this.intl.t('iam.users.index.re-activate-user-success-message', { userName: user.get('name') }));
+ this.hostRouter.refresh();
+ } catch (error) {
+ this.notifications.serverError(error);
+ modal.stopLoading();
+ }
+ },
+ });
+ }
+
+ /**
+ * Verify a user
+ *
+ * @void
+ */
+ @action verifyUser(user) {
+ return this.markVerified(user, 'email');
+ }
+
+ /**
+ * Manually mark a user's email or phone as verified, bypassing verification.
+ *
+ * @param {UserModel} user
+ * @param {String} channel `email` or `phone`
+ * @void
+ */
+ @action markVerified(user, channel = 'email') {
+ this.modalsManager.confirm({
+ title: this.intl.t('iam.users.index.mark-verified-title', { userName: user.get('name'), channel: this.channelLabel(channel) }),
+ body: this.intl.t('iam.users.index.mark-verified-prompt', { channel: this.channelLabel(channel) }),
+ confirm: async (modal) => {
+ modal.startLoading();
+
+ try {
+ await this.fetch.patch(`users/verify/${user.id}`, { channel });
+ this.notifications.success(this.intl.t('iam.users.index.user-verified-success-message', { userName: user.get('name') }));
+ this.hostRouter.refresh();
+ modal.done();
+ } catch (error) {
+ this.notifications.serverError(error);
+ modal.stopLoading();
+ }
+ },
+ });
+ }
+
+ /**
+ * Send the user a link to verify their email or phone.
+ *
+ * @param {UserModel} user
+ * @param {String} channel `email` or `phone`
+ * @void
+ */
+ @action sendVerification(user, channel = 'email') {
+ const destination = user.get(channel);
+
+ this.modalsManager.confirm({
+ title: this.intl.t('iam.users.index.send-verification-title', { channel: this.channelLabel(channel) }),
+ body: this.intl.t('iam.users.index.send-verification-prompt', { userName: user.get('name'), destination }),
+ acceptButtonText: this.intl.t('iam.users.index.send-verification'),
+ acceptButtonIcon: 'paper-plane',
+ confirm: async (modal) => {
+ modal.startLoading();
+
+ try {
+ await this.fetch.post(`users/${user.id}/send-verification`, { channel });
+ this.notifications.success(this.intl.t('iam.users.index.verification-sent', { destination }));
+ modal.done();
+ } catch (error) {
+ this.notifications.serverError(error);
+ modal.stopLoading();
+ }
+ },
+ });
+ }
+
+ /**
+ * The translated name of a verification channel.
+ *
+ * @param {String} channel `email` or `phone`
+ * @return {String}
+ */
+ channelLabel(channel) {
+ return this.intl.t(channel === 'phone' ? 'iam.users.index.channel-phone' : 'iam.users.index.channel-email');
+ }
+
+ /**
+ * Change password for a user
+ *
+ * @void
+ */
+ @action changeUserPassword(user) {
+ this.modalsManager.show('modals/change-user-password', {
+ keepOpen: true,
+ user,
+ });
+ }
+
+ /**
+ * Change email for a user
+ *
+ * @void
+ */
+ @action changeUserEmail(user) {
+ this.modalsManager.show('modals/change-user-email', {
+ keepOpen: true,
+ user,
+ onEmailChangeComplete: () => {
+ return this.hostRouter.refresh();
+ },
+ });
+ }
+
+ /**
+ * Resends invite for a user to join.
+ *
+ * @void
+ */
+ @action resendInvitation(user) {
+ this.modalsManager.confirm({
+ title: this.intl.t('iam.users.index.resend-invitation-to-join-organization'),
+ body: this.intl.t('iam.users.index.confirming-fleetbase-will-re-send-invitation-for-user-to-join-your-organization'),
+ confirm: async (modal) => {
+ modal.startLoading();
+
+ try {
+ await user.resendInvite();
+ this.notifications.success(this.intl.t('iam.users.index.invitation-resent'));
+ this.hostRouter.refresh();
+ } catch (error) {
+ this.notifications.serverError(error);
+ modal.stopLoading();
+ }
+ },
+ });
+ }
+}
diff --git a/addon/templates/users.hbs b/addon/templates/users.hbs
index 9ad4eaa..90845fb 100644
--- a/addon/templates/users.hbs
+++ b/addon/templates/users.hbs
@@ -1,19 +1,14 @@
-
- <:actions>
-
-
-
-
- <:default>
- {{outlet}}
-
-
\ No newline at end of file
+
+
+
+
+{{outlet}}
\ No newline at end of file
diff --git a/addon/templates/users/customers.hbs b/addon/templates/users/customers.hbs
deleted file mode 100644
index 9c9f736..0000000
--- a/addon/templates/users/customers.hbs
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
-{{outlet}}
\ No newline at end of file
diff --git a/addon/templates/users/drivers.hbs b/addon/templates/users/drivers.hbs
deleted file mode 100644
index 9c9f736..0000000
--- a/addon/templates/users/drivers.hbs
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
-{{outlet}}
\ No newline at end of file
diff --git a/app/components/table/cell/verification-status.js b/app/components/table/cell/verification-status.js
new file mode 100644
index 0000000..a1eb9e5
--- /dev/null
+++ b/app/components/table/cell/verification-status.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/iam-engine/components/table/cell/verification-status';
diff --git a/app/controllers/users/customers.js b/app/controllers/users/customers.js
deleted file mode 100644
index ca8c41b..0000000
--- a/app/controllers/users/customers.js
+++ /dev/null
@@ -1 +0,0 @@
-export { default } from '@fleetbase/iam-engine/controllers/users/customers';
diff --git a/app/controllers/users/drivers.js b/app/controllers/users/drivers.js
deleted file mode 100644
index 9004faa..0000000
--- a/app/controllers/users/drivers.js
+++ /dev/null
@@ -1 +0,0 @@
-export { default } from '@fleetbase/iam-engine/controllers/users/drivers';
diff --git a/app/routes/users/customers.js b/app/routes/users/customers.js
deleted file mode 100644
index 4e686b9..0000000
--- a/app/routes/users/customers.js
+++ /dev/null
@@ -1 +0,0 @@
-export { default } from '@fleetbase/iam-engine/routes/users/customers';
diff --git a/app/routes/users/drivers.js b/app/routes/users/drivers.js
deleted file mode 100644
index 135a440..0000000
--- a/app/routes/users/drivers.js
+++ /dev/null
@@ -1 +0,0 @@
-export { default } from '@fleetbase/iam-engine/routes/users/drivers';
diff --git a/app/services/group-actions.js b/app/services/group-actions.js
new file mode 100644
index 0000000..56686d4
--- /dev/null
+++ b/app/services/group-actions.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/iam-engine/services/group-actions';
diff --git a/app/services/policy-actions.js b/app/services/policy-actions.js
new file mode 100644
index 0000000..bcd2423
--- /dev/null
+++ b/app/services/policy-actions.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/iam-engine/services/policy-actions';
diff --git a/app/services/role-actions.js b/app/services/role-actions.js
new file mode 100644
index 0000000..1bd4b92
--- /dev/null
+++ b/app/services/role-actions.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/iam-engine/services/role-actions';
diff --git a/app/services/user-actions.js b/app/services/user-actions.js
new file mode 100644
index 0000000..fe73834
--- /dev/null
+++ b/app/services/user-actions.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/iam-engine/services/user-actions';
diff --git a/app/templates/users/customers.js b/app/templates/users/customers.js
deleted file mode 100644
index 1fb0fd2..0000000
--- a/app/templates/users/customers.js
+++ /dev/null
@@ -1 +0,0 @@
-export { default } from '@fleetbase/iam-engine/templates/users/customers';
diff --git a/app/templates/users/drivers.js b/app/templates/users/drivers.js
deleted file mode 100644
index 26fc0bb..0000000
--- a/app/templates/users/drivers.js
+++ /dev/null
@@ -1 +0,0 @@
-export { default } from '@fleetbase/iam-engine/templates/users/drivers';
diff --git a/package.json b/package.json
index b3afe06..f9960b6 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@fleetbase/iam-engine",
- "version": "0.1.11",
+ "version": "0.1.12",
"description": "Fleetbase IAM extension provides identity and access management module for managing users, permissions and policies.",
"fleetbase": {
"route": "iam"
@@ -43,7 +43,7 @@
"dependencies": {
"@babel/core": "^7.23.2",
"@fleetbase/ember-core": "^0.3.24",
- "@fleetbase/ember-ui": "^0.3.41",
+ "@fleetbase/ember-ui": "^0.4.2",
"@fortawesome/ember-fontawesome": "^2.0.0",
"@fortawesome/fontawesome-svg-core": "6.4.0",
"@fortawesome/free-brands-svg-icons": "6.4.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2202c27..d13f922 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -18,8 +18,8 @@ importers:
specifier: ^0.3.24
version: 0.3.24(@ember/string@3.1.1)(@ember/test-helpers@3.3.1(@babel/core@7.29.7)(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15)))(webpack@5.107.2(postcss@8.5.15)))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15))))(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15)))(eslint@8.57.1)(webpack@5.107.2(postcss@8.5.15))
'@fleetbase/ember-ui':
- specifier: ^0.3.41
- version: 0.3.41(@ember/test-helpers@3.3.1(@babel/core@7.29.7)(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15)))(webpack@5.107.2(postcss@8.5.15)))(@glimmer/component@1.1.2(@babel/core@7.29.7))(@glimmer/tracking@1.1.2)(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15))))(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15)))(postcss@8.5.15)(rollup@2.80.0)(tracked-built-ins@3.4.0(@babel/core@7.29.7))(webpack@5.107.2(postcss@8.5.15))(yaml@2.9.0)
+ specifier: ^0.4.2
+ version: 0.4.2(@ember/test-helpers@3.3.1(@babel/core@7.29.7)(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15)))(webpack@5.107.2(postcss@8.5.15)))(@glimmer/component@1.1.2(@babel/core@7.29.7))(@glimmer/tracking@1.1.2)(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15))))(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15)))(postcss@8.5.15)(rollup@2.80.0)(tracked-built-ins@3.4.0(@babel/core@7.29.7))(webpack@5.107.2(postcss@8.5.15))(yaml@2.9.0)
'@fortawesome/ember-fontawesome':
specifier: ^2.0.0
version: 2.0.0(@babel/core@7.29.7)(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15)))(rollup@2.80.0)(webpack@5.107.2(postcss@8.5.15))
@@ -1252,8 +1252,8 @@ packages:
resolution: {integrity: sha512-WG32JlX4S75ofa34RQEMxFEqc20fTv2Qn3A5+NlqXtL0lfnCthdO+a1DrGqIIierthHl3mVTxhe+z63f8sM6EA==}
engines: {node: '>= 18'}
- '@fleetbase/ember-ui@0.3.41':
- resolution: {integrity: sha512-xWWrqHnVj2YW1dfEGC5woLrpPEGx8Z6dstxVZygjt9apf8Vu3goyNnLSdkIdbJS2ACMlg+QuAUV5tPySQuO+3w==}
+ '@fleetbase/ember-ui@0.4.2':
+ resolution: {integrity: sha512-jRpu9fYIKis9QDvOr6Ih2zPbIrGhHa+f8tLzKJCl4bFtqtYMGlr0XNW/ckp4VLmoPmWPwJHmVbtvmwLeVoEmhQ==}
engines: {node: '>= 18'}
'@fleetbase/intl-lint@0.0.1':
@@ -9537,7 +9537,7 @@ snapshots:
dependencies:
postcss: 8.5.15
- '@ember-data/adapter@4.12.8(@babel/core@7.29.7)(@ember-data/store@4.12.8(@babel/core@7.29.7)(@ember-data/graph@4.12.8)(@ember-data/json-api@4.12.8)(@ember-data/legacy-compat@4.12.8)(@ember-data/model@4.12.8)(@ember-data/tracking@4.12.8(@babel/core@7.29.7))(@ember/string@3.1.1)(@glimmer/tracking@1.1.2)(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15))))(@ember/string@3.1.1)(ember-inflector@4.0.3(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15))))':
+ '@ember-data/adapter@4.12.8(@babel/core@7.29.7)(@ember-data/store@4.12.8)(@ember/string@3.1.1)(ember-inflector@4.0.3(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15))))':
dependencies:
'@ember-data/private-build-infra': 4.12.8
'@ember-data/store': 4.12.8(@babel/core@7.29.7)(@ember-data/graph@4.12.8)(@ember-data/json-api@4.12.8)(@ember-data/legacy-compat@4.12.8)(@ember-data/model@4.12.8)(@ember-data/tracking@4.12.8(@babel/core@7.29.7))(@ember/string@3.1.1)(@glimmer/tracking@1.1.2)(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15)))
@@ -9674,7 +9674,7 @@ snapshots:
'@ember-data/rfc395-data@0.0.4': {}
- '@ember-data/serializer@4.12.8(@babel/core@7.29.7)(@ember-data/store@4.12.8(@babel/core@7.29.7)(@ember-data/graph@4.12.8)(@ember-data/json-api@4.12.8)(@ember-data/legacy-compat@4.12.8)(@ember-data/model@4.12.8)(@ember-data/tracking@4.12.8(@babel/core@7.29.7))(@ember/string@3.1.1)(@glimmer/tracking@1.1.2)(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15))))(@ember/string@3.1.1)(ember-inflector@4.0.3(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15))))':
+ '@ember-data/serializer@4.12.8(@babel/core@7.29.7)(@ember-data/store@4.12.8)(@ember/string@3.1.1)(ember-inflector@4.0.3(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15))))':
dependencies:
'@ember-data/private-build-infra': 4.12.8
'@ember-data/store': 4.12.8(@babel/core@7.29.7)(@ember-data/graph@4.12.8)(@ember-data/json-api@4.12.8)(@ember-data/legacy-compat@4.12.8)(@ember-data/model@4.12.8)(@ember-data/tracking@4.12.8(@babel/core@7.29.7))(@ember/string@3.1.1)(@glimmer/tracking@1.1.2)(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15)))
@@ -9960,7 +9960,7 @@ snapshots:
- utf-8-validate
- webpack
- '@fleetbase/ember-ui@0.3.41(@ember/test-helpers@3.3.1(@babel/core@7.29.7)(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15)))(webpack@5.107.2(postcss@8.5.15)))(@glimmer/component@1.1.2(@babel/core@7.29.7))(@glimmer/tracking@1.1.2)(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15))))(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15)))(postcss@8.5.15)(rollup@2.80.0)(tracked-built-ins@3.4.0(@babel/core@7.29.7))(webpack@5.107.2(postcss@8.5.15))(yaml@2.9.0)':
+ '@fleetbase/ember-ui@0.4.2(@ember/test-helpers@3.3.1(@babel/core@7.29.7)(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15)))(webpack@5.107.2(postcss@8.5.15)))(@glimmer/component@1.1.2(@babel/core@7.29.7))(@glimmer/tracking@1.1.2)(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15))))(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15)))(postcss@8.5.15)(rollup@2.80.0)(tracked-built-ins@3.4.0(@babel/core@7.29.7))(webpack@5.107.2(postcss@8.5.15))(yaml@2.9.0)':
dependencies:
'@babel/core': 7.29.7
'@ember/render-modifiers': 2.1.0(@babel/core@7.29.7)(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15)))
@@ -13149,7 +13149,7 @@ snapshots:
'@embroider/util': 1.13.5(@babel/core@7.29.7)(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15)))
'@glimmer/component': 1.1.2(@babel/core@7.29.7)
'@glimmer/tracking': 1.1.2
- decorator-transforms: 2.3.2(@babel/core@7.29.7)
+ decorator-transforms: 2.4.0(@babel/core@7.29.7)
ember-element-helper: 0.8.8
ember-lifeline: 7.0.0(@ember/test-helpers@3.3.1(@babel/core@7.29.7)(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15)))(webpack@5.107.2(postcss@8.5.15)))
ember-modifier: 4.3.0(@babel/core@7.29.7)
@@ -13771,7 +13771,7 @@ snapshots:
ember-data@4.12.8(@babel/core@7.29.7)(@ember/string@3.1.1)(@glimmer/tracking@1.1.2)(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15)))(webpack@5.107.2(postcss@8.5.15)):
dependencies:
- '@ember-data/adapter': 4.12.8(@babel/core@7.29.7)(@ember-data/store@4.12.8(@babel/core@7.29.7)(@ember-data/graph@4.12.8)(@ember-data/json-api@4.12.8)(@ember-data/legacy-compat@4.12.8)(@ember-data/model@4.12.8)(@ember-data/tracking@4.12.8(@babel/core@7.29.7))(@ember/string@3.1.1)(@glimmer/tracking@1.1.2)(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15))))(@ember/string@3.1.1)(ember-inflector@4.0.3(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15))))
+ '@ember-data/adapter': 4.12.8(@babel/core@7.29.7)(@ember-data/store@4.12.8)(@ember/string@3.1.1)(ember-inflector@4.0.3(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15))))
'@ember-data/debug': 4.12.8(@babel/core@7.29.7)(@ember-data/store@4.12.8)(@ember/string@3.1.1)(webpack@5.107.2(postcss@8.5.15))
'@ember-data/graph': 4.12.8(@babel/core@7.29.7)(@ember-data/store@4.12.8)
'@ember-data/json-api': 4.12.8(@babel/core@7.29.7)(@ember-data/graph@4.12.8)(@ember-data/store@4.12.8)
@@ -13779,7 +13779,7 @@ snapshots:
'@ember-data/model': 4.12.8(@babel/core@7.29.7)(@ember-data/debug@4.12.8)(@ember-data/graph@4.12.8)(@ember-data/json-api@4.12.8)(@ember-data/legacy-compat@4.12.8)(@ember-data/store@4.12.8)(@ember-data/tracking@4.12.8(@babel/core@7.29.7))(@ember/string@3.1.1)(ember-inflector@4.0.3(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15))))(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15)))
'@ember-data/private-build-infra': 4.12.8
'@ember-data/request': 4.12.8(@babel/core@7.29.7)
- '@ember-data/serializer': 4.12.8(@babel/core@7.29.7)(@ember-data/store@4.12.8(@babel/core@7.29.7)(@ember-data/graph@4.12.8)(@ember-data/json-api@4.12.8)(@ember-data/legacy-compat@4.12.8)(@ember-data/model@4.12.8)(@ember-data/tracking@4.12.8(@babel/core@7.29.7))(@ember/string@3.1.1)(@glimmer/tracking@1.1.2)(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15))))(@ember/string@3.1.1)(ember-inflector@4.0.3(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15))))
+ '@ember-data/serializer': 4.12.8(@babel/core@7.29.7)(@ember-data/store@4.12.8)(@ember/string@3.1.1)(ember-inflector@4.0.3(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15))))
'@ember-data/store': 4.12.8(@babel/core@7.29.7)(@ember-data/graph@4.12.8)(@ember-data/json-api@4.12.8)(@ember-data/legacy-compat@4.12.8)(@ember-data/model@4.12.8)(@ember-data/tracking@4.12.8(@babel/core@7.29.7))(@ember/string@3.1.1)(@glimmer/tracking@1.1.2)(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15)))
'@ember-data/tracking': 4.12.8(@babel/core@7.29.7)
'@ember/edition-utils': 1.2.0
@@ -13892,7 +13892,7 @@ snapshots:
ember-focus-trap@1.2.0(@babel/core@7.29.7):
dependencies:
'@embroider/addon-shim': 1.10.3
- decorator-transforms: 2.3.2(@babel/core@7.29.7)
+ decorator-transforms: 2.4.0(@babel/core@7.29.7)
focus-trap: 7.8.0
transitivePeerDependencies:
- '@babel/core'
@@ -14069,7 +14069,7 @@ snapshots:
ember-modifier@4.3.0(@babel/core@7.29.7):
dependencies:
'@embroider/addon-shim': 1.10.3
- decorator-transforms: 2.3.2(@babel/core@7.29.7)
+ decorator-transforms: 2.4.0(@babel/core@7.29.7)
transitivePeerDependencies:
- '@babel/core'
- supports-color
@@ -14112,7 +14112,7 @@ snapshots:
'@embroider/util': 1.13.5(@babel/core@7.29.7)(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15)))
'@glimmer/component': 1.1.2(@babel/core@7.29.7)
'@glimmer/tracking': 1.1.2
- decorator-transforms: 2.3.2(@babel/core@7.29.7)
+ decorator-transforms: 2.4.0(@babel/core@7.29.7)
ember-assign-helper: 0.5.1
ember-basic-dropdown: 8.4.0(@ember/test-helpers@3.3.1(@babel/core@7.29.7)(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15)))(webpack@5.107.2(postcss@8.5.15)))(@glimmer/component@1.1.2(@babel/core@7.29.7))(@glimmer/tracking@1.1.2)(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15)))
ember-concurrency: 4.0.6(@babel/core@7.29.7)
@@ -14296,7 +14296,7 @@ snapshots:
dependencies:
'@embroider/addon-shim': 1.10.3
csstype: 3.2.3
- decorator-transforms: 2.3.2(@babel/core@7.29.7)
+ decorator-transforms: 2.4.0(@babel/core@7.29.7)
ember-modifier: 4.3.0(@babel/core@7.29.7)
transitivePeerDependencies:
- '@babel/core'
@@ -16450,7 +16450,7 @@ snapshots:
md5.js@1.3.5:
dependencies:
- hash-base: 3.0.5
+ hash-base: 3.1.2
inherits: 2.0.4
safe-buffer: 5.2.1
diff --git a/tests/unit/controllers/application-test.js b/tests/unit/controllers/application-test.js
index 1cc6e1b..e67bec3 100644
--- a/tests/unit/controllers/application-test.js
+++ b/tests/unit/controllers/application-test.js
@@ -54,7 +54,7 @@ module('Unit | Controller | application', function (hooks) {
this.owner.register('service:fetch', FetchStub);
});
- test('it builds IAM sidebar navigator items with host routes and nested users', function (assert) {
+ test('it builds IAM sidebar navigator items with host routes', function (assert) {
const controller = this.owner.lookup('controller:application');
const items = controller.navigationItems;
const users = items.find((item) => item.route === 'console.iam.users');
@@ -64,16 +64,7 @@ module('Unit | Controller | application', function (hooks) {
['console.iam.home', 'console.iam.users', 'console.iam.groups', 'console.iam.roles', 'console.iam.policies'],
'root items keep the console host route names'
);
- assert.deepEqual(
- users.children.map((item) => item.route),
- ['console.iam.users.index', 'console.iam.users.drivers', 'console.iam.users.customers'],
- 'users item exposes child routes for nested navigator testing'
- );
- assert.deepEqual(
- users.children.map((item) => item.label),
- ['Users', 'Drivers', 'Customers'],
- 'users item labels distinguish actual users from drivers and customers'
- );
+ assert.strictEqual(users.children, undefined, 'users item is a single staff list without driver/customer sub-sections');
assert.true(items[0].tooltip, 'dashboard opts into description-backed tooltip behavior');
assert.strictEqual(users.permission, 'iam list user', 'users item keeps list permission filtering');
assert.true(users.visible, 'users item is visible when the see permission is allowed');
diff --git a/tests/unit/controllers/users/customers-test.js b/tests/unit/controllers/users/customers-test.js
deleted file mode 100644
index a06970d..0000000
--- a/tests/unit/controllers/users/customers-test.js
+++ /dev/null
@@ -1,12 +0,0 @@
-import { module, test } from 'qunit';
-import { setupTest } from 'dummy/tests/helpers';
-
-module('Unit | Controller | users/customers', function (hooks) {
- setupTest(hooks);
-
- // TODO: Replace this with your real tests.
- test('it exists', function (assert) {
- let controller = this.owner.lookup('controller:users/customers');
- assert.ok(controller);
- });
-});
diff --git a/tests/unit/controllers/users/drivers-test.js b/tests/unit/controllers/users/drivers-test.js
deleted file mode 100644
index 674f2b7..0000000
--- a/tests/unit/controllers/users/drivers-test.js
+++ /dev/null
@@ -1,12 +0,0 @@
-import { module, test } from 'qunit';
-import { setupTest } from 'dummy/tests/helpers';
-
-module('Unit | Controller | users/drivers', function (hooks) {
- setupTest(hooks);
-
- // TODO: Replace this with your real tests.
- test('it exists', function (assert) {
- let controller = this.owner.lookup('controller:users/drivers');
- assert.ok(controller);
- });
-});
diff --git a/tests/unit/controllers/users/index-test.js b/tests/unit/controllers/users/index-test.js
index 3e987da..c657f42 100644
--- a/tests/unit/controllers/users/index-test.js
+++ b/tests/unit/controllers/users/index-test.js
@@ -9,4 +9,27 @@ module('Unit | Controller | users/index', function (hooks) {
let controller = this.owner.lookup('controller:users/index');
assert.ok(controller);
});
+
+ test('it offers verification only for a set, unverified email or phone', function (assert) {
+ const controller = this.owner.lookup('controller:users/index');
+ const user = (attributes) => ({ get: (key) => attributes[key] });
+
+ assert.true(controller.canVerify(user({ email: 'ada@example.test', email_verified_at: null }), 'email'));
+ assert.false(controller.canVerify(user({ email: 'ada@example.test', email_verified_at: '2026-09-22' }), 'email'));
+ assert.false(controller.canVerify(user({ phone: null, phone_verified_at: null }), 'phone'));
+ assert.true(controller.canVerify(user({ phone: '+15550001111', phone_verified_at: null }), 'phone'));
+ });
+
+ test('it shows verification and profile columns, with the profile ones hidden by default', function (assert) {
+ const controller = this.owner.lookup('controller:users/index');
+ const column = (valuePath) => controller.columns.find((c) => c.valuePath === valuePath);
+
+ assert.strictEqual(column('email_verified_at').filterParam, 'email_verified');
+ assert.strictEqual(column('phone_verified_at').filterParam, 'phone_verified');
+ assert.deepEqual(
+ controller.verificationFilterOptions.map((option) => option.value),
+ ['true', 'false']
+ );
+ ['country', 'timezone', 'date_of_birth', 'ip_address'].forEach((valuePath) => assert.true(column(valuePath).hidden, `${valuePath} is hidden by default`));
+ });
});
diff --git a/tests/unit/routes/users/customers-test.js b/tests/unit/routes/users/customers-test.js
deleted file mode 100644
index 4ffde7b..0000000
--- a/tests/unit/routes/users/customers-test.js
+++ /dev/null
@@ -1,11 +0,0 @@
-import { module, test } from 'qunit';
-import { setupTest } from 'dummy/tests/helpers';
-
-module('Unit | Route | users/customers', function (hooks) {
- setupTest(hooks);
-
- test('it exists', function (assert) {
- let route = this.owner.lookup('route:users/customers');
- assert.ok(route);
- });
-});
diff --git a/tests/unit/routes/users/drivers-test.js b/tests/unit/routes/users/drivers-test.js
deleted file mode 100644
index 34c98c6..0000000
--- a/tests/unit/routes/users/drivers-test.js
+++ /dev/null
@@ -1,11 +0,0 @@
-import { module, test } from 'qunit';
-import { setupTest } from 'dummy/tests/helpers';
-
-module('Unit | Route | users/drivers', function (hooks) {
- setupTest(hooks);
-
- test('it exists', function (assert) {
- let route = this.owner.lookup('route:users/drivers');
- assert.ok(route);
- });
-});
diff --git a/tests/unit/services/user-actions-test.js b/tests/unit/services/user-actions-test.js
new file mode 100644
index 0000000..7d8e7fa
--- /dev/null
+++ b/tests/unit/services/user-actions-test.js
@@ -0,0 +1,90 @@
+import { module, test } from 'qunit';
+import { setupTest } from 'dummy/tests/helpers';
+import Service from '@ember/service';
+
+class ModalsManagerStub extends Service {
+ shown = [];
+
+ show(component, options) {
+ this.shown.push({ component, options });
+ }
+}
+
+class StoreStub extends Service {
+ createRecord(modelName, attributes) {
+ return { modelName, ...attributes };
+ }
+}
+
+class AbilitiesStub extends Service {
+ cannot() {
+ return false;
+ }
+}
+
+class IntlStub extends Service {
+ t(key) {
+ return key;
+ }
+}
+
+module('Unit | Service | user-actions', function (hooks) {
+ setupTest(hooks);
+
+ hooks.beforeEach(function () {
+ this.owner.register('service:modals-manager', ModalsManagerStub);
+ this.owner.register('service:store', StoreStub);
+ this.owner.register('service:abilities', AbilitiesStub);
+ this.owner.register('service:intl', IntlStub);
+ });
+
+ test('modal.create opens the user form for a new pending user', function (assert) {
+ const service = this.owner.lookup('service:user-actions');
+ const modals = this.owner.lookup('service:modals-manager');
+
+ service.modal.create();
+
+ assert.strictEqual(modals.shown.length, 1);
+ assert.strictEqual(modals.shown[0].component, 'modals/user-form');
+ assert.strictEqual(modals.shown[0].options.user.modelName, 'user');
+ assert.strictEqual(modals.shown[0].options.user.status, 'pending');
+ assert.strictEqual(modals.shown[0].options.formPermission, 'iam create user');
+ assert.true(modals.shown[0].options.allowEmailEdit);
+ });
+
+ test('modal.invite opens the invite user form', function (assert) {
+ const service = this.owner.lookup('service:user-actions');
+ const modals = this.owner.lookup('service:modals-manager');
+
+ service.modal.invite();
+
+ assert.strictEqual(modals.shown[0].component, 'modals/invite-user');
+ assert.strictEqual(modals.shown[0].options.email, '');
+ });
+});
+
+module('Unit | Service | group, role, and policy actions', function (hooks) {
+ setupTest(hooks);
+
+ hooks.beforeEach(function () {
+ this.owner.register('service:modals-manager', ModalsManagerStub);
+ this.owner.register('service:store', StoreStub);
+ this.owner.register('service:abilities', AbilitiesStub);
+ this.owner.register('service:intl', IntlStub);
+ });
+
+ test('modal.create opens each resource form', function (assert) {
+ const modals = this.owner.lookup('service:modals-manager');
+
+ this.owner.lookup('service:group-actions').modal.create();
+ this.owner.lookup('service:role-actions').modal.create();
+ this.owner.lookup('service:policy-actions').modal.create();
+
+ assert.deepEqual(
+ modals.shown.map((modal) => modal.component),
+ ['modals/group-form', 'modals/role-form', 'modals/policy-form']
+ );
+ assert.strictEqual(modals.shown[1].options.role.is_mutable, true);
+ assert.strictEqual(modals.shown[2].options.policy.is_deletable, true);
+ });
+});
diff --git a/translations/en-us.yaml b/translations/en-us.yaml
index 2d63e1b..aed923a 100644
--- a/translations/en-us.yaml
+++ b/translations/en-us.yaml
@@ -138,9 +138,26 @@ iam:
deactivate-user-success-message: User {userName} deactivated.
re-activate-user-title: Re-activate {userName} user's account
re-activate-user-success-message: User {userName} activated.
- verify-user-title: Manually Verify user {userName} account
- verify-user-manually-prompt: By clicking confirm this action will manually verify the users email address. Are you sure you want to bypass verification for this user?
user-verified-success-message: User {userName} verified.
+ mark-verified-title: Mark {userName}'s {channel} as verified
+ mark-verified-prompt: This marks the user's {channel} as verified without them confirming it. Are you sure?
+ send-verification-title: Send {channel} verification
+ send-verification-prompt: '{userName} will receive a link at {destination} to confirm it. The link expires in 48 hours.'
+ send-verification: Send verification
+ verification-sent: Verification link sent to {destination}.
+ channel-email: email address
+ channel-phone: phone number
+ send-email-verification: Send email verification...
+ send-phone-verification: Send phone verification...
+ mark-email-verified: Mark email verified...
+ mark-phone-verified: Mark phone verified...
+ email-verified: Email Verified
+ phone-verified: Phone Verified
+ verified: Verified
+ unverified: Unverified
+ timezone: Timezone
+ date-of-birth: Date of Birth
+ ip-address: IP Address
delete-users: Delete Users
last-login: Last Login
created-at: Created At
@@ -152,7 +169,6 @@ iam:
deactivate-user: Deactivate user...
activate-user: Activate user...
delete-user: Delete user...
- verify-user: Verify user...
change-user-password: Change user password...
change-user-email: Change user email...
new-user: New User
@@ -182,6 +198,9 @@ iam:
email-required: Please enter an email address.
invitation-sent-existing: Invitation sent. The user will receive an email to join your organisation.
invitation-sent-new: Invitation sent. A pending account has been created and the user will be asked to set a password.
+ role-required: Select a role for this user.
+ notifications:
+ promoted-existing-account: Existing {type} account for {name} was upgraded to a team member.
application:
access-management: Access Management
home:
diff --git a/translations/uk-ua.yaml b/translations/uk-ua.yaml
new file mode 100644
index 0000000..2eecf5b
--- /dev/null
+++ b/translations/uk-ua.yaml
@@ -0,0 +1,188 @@
+iam:
+ extension-name: IAM
+ common:
+ address: Адреса
+ bulk-action: Масова дія
+ country: Країна
+ create: Створено
+ dashboard: Панель керування
+ description: Опис
+ email: Електронна пошта
+ export: Експорт
+ group: Групи
+ member: Учасники
+ name: Назва
+ new: Новий
+ permission: Дозвіл
+ phone: Телефон
+ photo: Фото
+ policy: Політика
+ policies: Політики
+ attach-policies: Прикріпити політики
+ select-policy: Вибрати політику
+ attach-permissions: Прикріпити дозволи
+ attach-inline-permissions: Прикріпити вбудовані дозволи
+ select-permissions: Вибрати дозволи
+ primary: Основний
+ role: Роль
+ roles: Ролі
+ status: Статус
+ type: Тип
+ user: Користувачі
+ service: Сервіс
+ reload: Перезавантажити
+ reload-data: Оновити дані
+ components:
+ widget:
+ metrics:
+ title: Метрики IAM
+ modals:
+ group-details:
+ group-name: Назва групи
+ group-form:
+ group-name: Назва групи
+ group-description: Опис групи
+ select-users-add-group: Виберіть користувачів для додавання до групи
+ search-select-users-add-group: Знайдіть та виберіть користувачів для додавання до цієї групи.
+ select-user-add-group: Виберіть користувача для додавання до групи
+ no-user-add-group: Жодного користувача не додано до групи
+ policy-form:
+ policy-name: Назва політики
+ enter-name-your-policy: Введіть назву вашої політики
+ policy-description: Опис політики
+ enter-description-your-policy: Введіть опис вашої політики
+ select-permissions: Вибрати дозволи
+ view-policy-permissions:
+ view-permissions: >-
+ Політика: {policyName} Дозволи
+ view-role-permissions:
+ view-permissions: >-
+ Роль: {roleName} Дозволи
+ view-user-permissions:
+ view-permissions: >-
+ Користувач: {userName} Дозволи
+ change-user-email:
+ title: Змінити email користувача
+ heading: Зміна email для {userName}
+ prompt: Введіть нову адресу електронної пошти. Користувач повинен підтвердити нову адресу перед тим, як вона стане його логіном.
+ current-email: Поточний email
+ new-email: Новий email
+ send-verification: Надіслати підтвердження
+ email-required: Потрібно вказати нову адресу електронної пошти.
+ verification-sent: Підтвердження зміни email надіслано. Користувач повинен підтвердити нову адресу, перш ніж зміниться його логін.
+ role-form:
+ role-name: Назва ролі
+ enter-name-for-this-role: Введіть назву для цієї ролі
+ role-description: Опис ролі
+ enter-description-your-role: Введіть опис вашої ролі
+ select-permission: Вибрати дозволи
+ table:
+ group-members:
+ no-member: Немає учасників
+ permission-picker:
+ selected: >-
+ Вибрано:
+ search-permissions-keyword: Шукати дозволи за ключовими словами
+ loading-permission: Завантаження дозволів...
+ groups:
+ index:
+ untitled: Без назви
+ delete-group-title: Видалити групу {groupName}
+ delete-group-success-message: Групу {name} видалено.
+ delete-group: Видалити групи
+ created: Створено
+ edit-group: Редагувати групу...
+ delete-group-label: Видалити групу...
+ new-group: Нова група
+ new-group-created: Нову групу створено.
+ edit-group-title: Редагувати групу
+ group-actions: Дії з групою
+ changes-group-save: Зміни в групі збережено.
+ data-assosciated-this-group-deleted: Ви впевнені, що хочете видалити цю групу? Усі дані, пов'язані з цією групою, також будуть видалені. Цю дію неможливо скасувати.
+ policies:
+ index:
+ title: Політики
+ delete-policies: Видалити політики
+ contact-action: Дії з контактами
+ edit-policy: Редагувати політику...
+ delete-policy: Видалити політику...
+ new-policy: Нова політика
+ new-policy-created: Нову політику створено.
+ unable-changes-policy-warning: Неможливо внести зміни до політики типу {policyType}.
+ edit-policy-title: Редагувати політику
+ policy-actions: Дії з політикою
+ changes-policy-saved-success: Зміни в політиці збережено.
+ unable-delete-policy-warning: Неможливо видалити політику типу {policyType}.
+ data-assosciated-this-policy-deleted: Ви впевнені, що хочете видалити цю політику? Усі дані, пов'язані з цією політикою, також будуть видалені. Цю дію неможливо скасувати.
+ policy-deleted: Політику {policyName} видалено.
+ roles:
+ index:
+ delete-roles: Видалити ролі
+ contact-action: Дії з контактами
+ edit-role: Редагувати роль...
+ delete-role: Видалити роль...
+ new-role: Нова роль
+ new-role-create: Нову роль створено
+ edit-role-title: Редагувати роль
+ role-actions: Дії з роллю
+ changes-role-saved: Зміни в ролі збережено.
+ data-assosciated-this-role-deleted: Ви впевнені, що хочете видалити цю роль? Усі дані, пов'язані з цією роллю, також будуть видалені. Цю дію неможливо скасувати.
+ role-deleted: Роль {roleName} видалено.
+ unable-changes-role-warning: Неможливо внести зміни до ролі типу {roleType}.
+ unable-delete-role-warning: Неможливо видалити роль типу {roleType}.
+ users:
+ index:
+ delete-user-title: Видалити користувача {userName}
+ delete-user-success-message: Користувача {userName} видалено.
+ deactivate-user-title: Деактивувати обліковий запис користувача {userName}
+ deactivate-user-success-message: Користувача {userName} деактивовано.
+ re-activate-user-title: Повторно активувати обліковий запис користувача {userName}
+ re-activate-user-success-message: Користувача {userName} активовано.
+ verify-user-title: Вручну підтвердити обліковий запис користувача {userName}
+ verify-user-manually-prompt: Підтверджуючи цю дію, ви вручну підтвердите адресу електронної пошти користувача. Ви впевнені, що хочете пропустити перевірку для цього користувача?
+ user-verified-success-message: Користувача {userName} підтверджено.
+ delete-users: Видалити користувачів
+ last-login: Останній вхід
+ created-at: Дата створення
+ updated-at: Дата оновлення
+ user-actions: Дії з користувачем
+ edit-user: Редагувати користувача...
+ view-user-permissions: Переглянути дозволи...
+ re-send-invitation: Повторно надіслати запрошення...
+ deactivate-user: Деактивувати користувача...
+ activate-user: Активувати користувача...
+ delete-user: Видалити користувача...
+ verify-user: Підтвердити користувача...
+ change-user-password: Змінити пароль користувача...
+ change-user-email: Змінити email користувача...
+ new-user: Новий користувач
+ user-invited-join-your-organization-success: Користувача запрошено приєднатися до вашої організації.
+ edit-user-title: Редагувати користувача
+ new-user-created: Нового користувача додано до організації.
+ user-changes-saved-success: Зміни даних користувача збережено.
+ error-you-cant-delete-yourself: Ви не можете видалити самого себе
+ data-assosciated-user-delete: Ви впевнені, що хочете видалити цього користувача? Усі дані, пов'язані з цим користувачем, також будуть видалені. Цю дію неможливо скасувати.
+ access-account-or-resources-unless-re-activated: Ви впевнені, що хочете деактивувати цього користувача? Цей користувач більше не матиме доступу до свого облікового запису або ресурсів, доки його не буде повторно активовано.
+ this-user-will-regain-access-to-your-organization: Ви впевнені, що хочете повторно активувати цього користувача? Цей користувач відновлюватиме доступ до вашої організації.
+ resend-invitation-to-join-organization: Повторно надіслати запрошення приєднатися до організації
+ confirming-fleetbase-will-re-send-invitation-for-user-to-join-your-organization: Підтверджуючи, Fleetbase повторно надішле запрошення цьому користувачеві приєднатися до вашої організації.
+ invitation-resent: Запрошення повторно надіслано.
+ invite-user: Запросити користувача
+ invite:
+ title: Запросити користувача
+ description: >-
+ Введіть адресу електронної пошти особи, яку ви хочете запросити. Якщо у неї
+ вже є обліковий запис Fleetbase, вона отримає запрошення приєднатися до вашої
+ організації. Якщо це новий користувач, буде створено обліковий запис з очікуванням
+ підтвердження, і після прийняття їй буде запропоновано встановити пароль.
+ email-placeholder: colleague@example.com
+ name-placeholder: Повне ім'я (обов'язково для нових користувачів)
+ name-help: Потрібно лише у випадку, якщо особа ще не має облікового запису Fleetbase.
+ send-invitation: Надіслати запрошення
+ email-required: Будь ласка, введіть адресу електронної пошти.
+ invitation-sent-existing: Запрошення надіслано. Користувач отримає електронний лист для приєднання до вашої організації.
+ invitation-sent-new: Запрошення надіслано. Створено обліковий запис з очікуванням підтвердження, і користувачеві буде запропоновано встановити пароль.
+ application:
+ access-management: Управління доступом
+ home:
+ identity-access-management: Управління ідентифікацією та доступом (IAM)
\ No newline at end of file