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