Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions addon/components/table/cell/verification-status.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{{#let (get @row @column.valuePath) as |verifiedAt|}}
{{#if verifiedAt}}
<Badge @status="success" @text={{t "iam.users.index.verified"}} @helpText={{format-date-fns verifiedAt}} />
{{else if (get @row @column.contactPath)}}
<Badge @status="warning" @text={{t "iam.users.index.unverified"}} />
{{else}}
<span class="text-gray-400 dark:text-gray-500">-</span>
{{/if}}
{{/let}}
8 changes: 8 additions & 0 deletions addon/components/table/cell/verification-status.js
Original file line number Diff line number Diff line change
@@ -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 {}
132 changes: 128 additions & 4 deletions addon/controllers/users/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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';

/**
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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'),
Expand Down Expand Up @@ -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.
*
Expand Down
58 changes: 55 additions & 3 deletions addon/services/user-actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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
*
Expand Down
1 change: 1 addition & 0 deletions app/components/table/cell/verification-status.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from '@fleetbase/iam-engine/components/table/cell/verification-status';
23 changes: 23 additions & 0 deletions tests/unit/controllers/users/index-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`));
});
});
22 changes: 19 additions & 3 deletions translations/en-us.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading