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