diff --git a/addon/components/table/cell/verification-status.hbs b/addon/components/table/cell/verification-status.hbs
new file mode 100644
index 0000000..bb82032
--- /dev/null
+++ b/addon/components/table/cell/verification-status.hbs
@@ -0,0 +1,9 @@
+{{#let (get @row @column.valuePath) as |verifiedAt|}}
+ {{#if verifiedAt}}
+
+ {{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/users/index.js b/addon/controllers/users/index.js
index 484f29f..00ad0b0 100644
--- a/addon/controllers/users/index.js
+++ b/addon/controllers/users/index.js
@@ -65,7 +65,25 @@ export default class UsersIndexController extends Controller {
];
}
- queryParams = ['view_user', 'page', 'limit', 'sort', 'query', '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;
@@ -75,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';
/**
@@ -135,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',
@@ -205,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) => 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) => !user.get('email_verified_at'),
+ isVisible: (user) => this.canVerify(user, 'phone'),
},
{
label: this.intl.t('iam.users.index.change-user-password'),
@@ -237,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.
*
diff --git a/addon/services/user-actions.js b/addon/services/user-actions.js
index eaf06c8..0008eff 100644
--- a/addon/services/user-actions.js
+++ b/addon/services/user-actions.js
@@ -316,16 +316,58 @@ export default class UserActionsService extends ResourceActionService {
* @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.verify-user-title', { userName: user.get('name') }),
- body: this.intl.t('iam.users.index.verify-user-manually-prompt'),
+ 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 user.verify();
+ 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();
@@ -334,6 +376,16 @@ export default class UserActionsService extends ResourceActionService {
});
}
+ /**
+ * 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
*
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/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/translations/en-us.yaml b/translations/en-us.yaml
index ec4757b..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