The user desk

Working /admin/crm/user — blocking and unblocking with a recorded reason, the four account statuses, resetting a customer's 2FA, CSV import and export, and the per-user detail page.

14 min readUpdated 6 August 2026users, blocking, two-factor, csv-import, roles

Almost every customer-facing incident lands here. A customer who cannot sign in, an account you suspect of fraud, a migration from an old platform, a chargeback that needs an account history — all of them start at Users → Users, /admin/crm/user, and continue on that customer's own page at /admin/crm/user/{id}.

This page is the admin side of that work. It names the buttons, the endpoints behind them and the states they write. What the customer sees is a different document.

Getting in

The list needs both access.user and view.user. With only the first you get the page, the header, the filters and a permanently empty table — no error, no banner. See Roles and permissions.

The buttons carry their own keys: create.user, edit.user, delete.user, plus import.user and export.user, which are the only two keys in the whole platform using those verbs. edit.user is the important one — it covers blocking, unblocking, status changes and resetting somebody's second factor.

/admin/crm/user is mapped to access.user; /admin/crm/user/{id} is not mapped at all, so it falls back to the catch-all access.admin. The data on it is still fetched through the API and still refused without view.user, so an under-privileged operator sees an empty shell rather than records — but the URL itself opens.

The list

/admin/crm/user is a data table over GET /api/admin/crm/user. Most of a record is folded into one compound User Details column — avatar, name, email, last login and role — and that column both sorts and filters. Beyond it, the columns you can both sort and filter on are phone, phoneVerified, status, emailVerified and createdAt. firstName, lastName and email sort but do not filter.

kyc.status and twoFactor.enabled can be neither sorted nor filtered, and neither can roleId or any of the profile and social fields. kyc.status is derived from the customer's KYC applications after the query has run, so the API has no column to order by; the rest are declared unsortable and unfilterable in the column config. Narrowing the list to "everyone without 2FA" or "everyone whose KYC was rejected" is therefore not something this table can do.

Create and Edit are on the table. Delete is not. The grid is configured canDelete: false deliberately; DELETE /api/admin/crm/user/{id} and the bulk DELETE /api/admin/crm/user do exist behind delete.user, but nothing in the product calls them. Deleting a customer row is not how you stop an account — block it or ban it, so the transactions, tickets and audit rows keep their owner.

The per-row menu carries exactly one operational action, and which one depends on the row: Block User when the status is ACTIVE or INACTIVE, and Unblock when it is SUSPENDED or BANNED.

Super Admin rows are hidden from the list for everybody who is not a Super Admin, and the per-id endpoints answer 404 rather than 403 for them — the premise being that a lesser administrator should not learn those accounts exist.

Blocking an account

Blocks an account with a recorded reason

There are two block dialogs — one behind the row menu on the list, one behind the Block button in the header of the customer's own page. They ask the same three things and call the same endpoint, but they are configured separately, and neither list matches the other.

Block type. A switch: permanent, or temporary with a duration.

Duration, only when temporary. The list dialog offers 1 Hour, 6 Hours, 12 Hours, 1 Day, 3 Days, 1 Week, 2 Weeks and 1 Month. The dialog on the customer's page offers 1 hour, 1 day, 1 week, 1 month and 1 year (8760 hours). The endpoint accepts anything from 1 to 8760 hours, so the customer's page is the only screen that reaches the ceiling, and a duration between the listed steps is possible through the API.

Reason, and it is mandatory. The choices depend on which screen you blocked from:

List dialog (/admin/crm/user) Customer's page (/admin/crm/user/{id})
Suspicious Activity Suspicious Activity
Terms of Service Violation Terms of Service Violation
Security Concerns Security Concern
Fraud Investigation Fraud Prevention
Compliance Review Money Laundering
Customer Request Multiple Account Violation
Admin Request
Other Other

Only Suspicious Activity, Terms of Service Violation and Other are common to both. "Security Concerns" and "Security Concern" are two different strings; Fraud Investigation, Compliance Review and Customer Request exist only on the list, while Fraud Prevention, Money Laundering, Multiple Account Violation and Admin Request exist only on the customer's page. The reason is stored as free text either way, so anything that groups blocks by reason will split those near-duplicates — decide as a team which screen your desk blocks from.

Choosing Other reveals a free-text box; whatever you type is stored instead of the word "Other". The reason is stored as text on the block row, between 1 and 1000 characters, and it is what you will be reading back to yourself in three months when the customer disputes it.

What the endpoint then does, in order:

  1. Refuses the impossible cases. You cannot block a Super Admin account and you cannot block yourself.

  2. Refuses a second block. If the account already has an active block — a permanent one, or a temporary one that has not expired — the call fails with "User is already blocked".

  3. Writes a block row into user_blocks, recording the target, you as adminId, the reason, whether it is temporary, the duration in hours and the computed blockedUntil.

  4. Sets the account status. A temporary block sets SUSPENDED; a permanent block sets BANNED.

  5. Revokes every session the account holds. This matters more than it sounds: login refuses every non-ACTIVE status, but a session that already exists never goes back through login, and its TTL is refreshed on every request. Without this step a banned customer would keep trading and withdrawing on the token they already had.

Sessions are torn down, but a socket that was upgraded before the block keeps its already-open frame pipe until it drops, because the connection is authenticated only at upgrade time. For a fraud case, confirm the account has actually gone quiet rather than assuming the block ended everything instantly.

Temporary blocks lift themselves — on the cron process

A temporary block is cleared by the scheduled job processExpiredUserBlocks, which runs every 15 minutes and handles up to 500 expired blocks per tick. For each one it deactivates the block row and, if the account has no other active block, moves it from SUSPENDED back to ACTIVE.

Two consequences worth knowing:

  • The reactivation only fires when the account is still SUSPENDED — the state the temporary block put it in. An account moved to BANNED or INACTIVE out of band is left where it is.
  • If the cron process is not running, no temporary block ever expires. The site keeps serving pages perfectly while suspended customers stay suspended. Check System → Scheduled Tasks (/admin/system/cron) if a customer says their week-long suspension never lifted.

You never have to wait for the cron. Unblock lifts it immediately.

Unblocking, and the block history

Lifts every active block and restores access

Unblock deactivates every row still flagged active for that user, not just the one it happened to find, and restores the status to ACTIVE — but only if the current status is SUSPENDED or BANNED. An account sitting at INACTIVE pending email verification is not promoted just because an unrelated block was cleared.

It also accepts the case where the status is blocked but no block row survives, which is what an out-of-band ban or a half-finished cron leaves behind. Refusing that would make the account unreachable from this screen.

Reads the whole block history for one account

This is the record you need when a customer disputes a suspension. Every block ever placed on the account, newest first, each with its reason, whether it was temporary, the duration, blockedUntil, whether it is still active, when it was created, and which administrator placed it — first name, last name and email. It is rendered on the customer's detail page.

Status is a different axis from blocking

The account's status column is a four-value enum on the user model: ACTIVE, INACTIVE, SUSPENDED, BANNED. The status endpoints accept only three of them.

Sets one account's status
Sets the status of several accounts

Both take ACTIVE | INACTIVE | BANNEDSUSPENDED is not offered, because it is the state the temporary-block machinery owns.

Status What it means How it is normally reached What login says
ACTIVE normal the default
INACTIVE account not usable yet or parked email verification pending, or set by hand "Your account is inactive. Please verify your email or contact support."
SUSPENDED temporarily blocked only by a temporary block "Your account is suspended. Please contact support."
BANNED permanently blocked a permanent block, or a status change "Your account has been banned. Please contact support."

The difference between a status change and a block is the paperwork. Setting BANNED from the status endpoint stops the account and revokes its sessions, exactly as a block does — but it writes no user_blocks row, so there is no reason, no admin name and nothing in the block history. Use Block for anything you might have to justify later, and the status field for housekeeping.

Both status routes refuse to change a Super Admin's status or your own, and both revoke sessions for any status that is not ACTIVE.

Resetting a customer's two-factor

Clears the account's 2FA so the customer can enrol again

This is the only remedy for a customer locked out of their authenticator app — a lost phone, a wiped device, a re-installed app. It is on the customer's detail page, not on the list.

It hard-deletes the account's row in two_factor, so the stale secret cannot linger and a fresh enrolment starts clean. It then writes a security.2fa_disabled entry into that customer's own activity timeline, at warning severity, recording which administrator did it.

What it does not do:

  • It does not touch the platform-wide Two-Factor Authentication switch in System → Platform Settings → Security, or any of the per-method switches. Those are settings; this is one row for one customer.
  • It does not disable 2FA policy for that account. If Require 2FA to Withdraw is on, the customer must enrol a new method before they can withdraw again — which is the point.
  • It does nothing at all if the account has no 2FA configured; it answers "User does not have 2FA configured" and stops.
  • It will not run against your own account. Disabling your own second factor goes through the password-gated flow in your own security settings, so a hijacked admin session cannot strip it.
  • It will not run against a Super Admin unless you are one — that combination is a complete account-takeover primitive.

This action hands the account back to whoever asks next. Nothing in the platform verifies that the person in the ticket is the account holder, and the audit entry will have your name on it.

Importing users from CSV

Creates accounts in bulk from a CSV file

The Import Users button appears only if you hold import.user. The dialog has a Download Template button; take it, because the header row is the contract.

email,firstName,lastName,password,phone,status,emailVerified,twoFactor,roleId,
avatar,bio,address,city,country,zip,facebook,twitter,instagram,github,dribbble,gitlab
  • Required: email, firstName, lastName. A row missing any of them is reported with the row number and skipped.
  • password is per row and optional. Any row that leaves it blank gets the Default Password from the dialog, which starts at Welcome123! — change it, or every imported account shares a password printed in this documentation.
  • status accepts ACTIVE, INACTIVE, BANNED, SUSPENDED, case-insensitively. Anything else falls back to ACTIVE rather than failing the row on a database constraint.
  • emailVerified reads true, 1 or yes as true.
  • twoFactor does nothing, and now says so. Two-factor lives in its own table and needs a secret an import cannot produce, so the column was being dropped on the floor while the import reported the row as fully imported. A row that sets it is still created, with a note in the results explaining that the customer must enrol 2FA themselves.
  • roleId is optional and defaults to the seeded User role. A row naming a role that does not exist is rejected; so is a row naming the Super Admin role, or any role holding permissions you do not hold yourself — see Creating an account into a role you do not hold. Because a CSV row carries a password as well as a role, this is the sharpest of the four ways to make an account, and it is gated the same way.
  • Column names are matched case-insensitively and accept both firstName and first_name spellings.

Send welcome email to imported users is a switch, off by default. Leave it off for a migration you have not announced yet.

The import is per row, not all-or-nothing. When it finishes, the dialog reports imported and failed and lists every failure as Row {n}: {email} — {reason}; the commonest reason is "User with this email already exists". Re-running the same file therefore re-imports nothing that already landed, which makes a partial import safe to retry.

A second reason worth recognising is "A deleted account still holds this email address" — a soft-deleted customer keeps the address reserved. See Reusing the email address of a deleted account.

Exporting

Two endpoints, both gated on export.user, and both dump the full customer list including personal data:

Exports every user as CSV
Exports every user as an Excel workbook, with role and KYC

The Export to CSV button on the toolbar calls the first one and downloads users_export_{date}.csv. It accepts an optional status query parameter (ACTIVE, INACTIVE, BANNED, SUSPENDED) to narrow the dump. The Excel workbook additionally resolves each customer's role and their effective KYC application.

It is deliberately separate from view.user: looking one customer up and walking out with the whole customer database are different privileges. Grant it to the roles you would trust with a copy of the database, and no others.

The customer's page

/admin/crm/user/{id} is the record. The header carries Block / Unblock and Reset 2FA; below it, tabs grouped into three bands.

Account — Overview, Transactions, Wallets, KYC, Support, Security, Activity and Audit Trail. Transactions, Wallets and Support are the same tables as their own admin screens, narrowed to this customer, so the detail dialogs behave identically.

Core trading — Binary Options and Spot always; Futures, Ecosystem, Forex Desk, Copy Trading and Trading Bots only when those extensions are enabled.

Extension services — Forex, AI Investments, ICO, P2P, Staking and the rest, again only where installed.

Two tabs are easy to confuse and are deliberately separate:

  • Activity is what the customer did. It reads GET /api/admin/crm/user/{id}/activity (up to 100 entries, 25 by default) and returns sign-ins, 2FA changes, API-key lifecycle and KYC events, each with the IP address and User-Agent recorded at the time. It is the tab for "was this login from somewhere unusual".
  • Audit Trail is what staff did to them — the admin audit rows for this record, plus the wallet balance ledger with the arithmetic (previousBalance → newBalance) beside each movement.

The Overview also computes an activity score out of 100 from verification state, engagement, compliance and addon usage. Treat it as a triage hint, not as evidence.

Roles are one field

An account points at exactly one role through user.roleId. There is no second role, no per-user permission override and no inheritance between roles. Two people who need different access need two different roles.

Changing an account's role is a Super Admin action, and the endpoint makes the caller re-enter their own password before it will save — the same gate that covers changing an account's email address. Super Admin is excluded from the role picker, so nobody can be promoted into it from this screen.

Creating an account into a role you do not hold

Editing a role and creating into one are separate questions, and until recently they had separate answers. Create refused only the role literally named Super Admin, so an operator holding nothing but create.user could make an account in the Admin role, read its temporary password out of the success message, and sign in — acquiring in two clicks every permission the role editor had been used to withhold from them. The CSV importer was the same door with the password chosen in the file.

Both now apply one rule:

You may create an account in any role whose permissions are a subset of your own. A Super Admin may create into any role but Super Admin.

That is the largest grant that cannot escalate — whatever the new account can do, you could already do yourself — and it leaves the ordinary use of create.user alone: a support lead can still create support accounts. When it refuses, it names the permissions that would have been handed out:

You cannot create an account in the "Admin" role: it holds permissions your own
role does not (access.admin, edit.role, delete.user). Ask a Super Admin to
create it.

If you see that and the account genuinely should exist, the answer is a Super Admin creating it, not a wider grant on your own role.

The edit form asks for a name and an email, and nothing else

First name, last name, email and — on create only — role are required. Everything else on the form is optional and can be left blank or cleared: phone, avatar, bio, address, city, country, zip and all six social links.

A blank optional field is stored as NULL, so clearing one really does remove it. A field you do fill in is still validated — a phone number must be E.164 (+254711972926) and a social link must be a real http(s) URL — so "optional" means "may be empty", not "unchecked".

This form used to reject a save with a wall of messages about fields it had marked optional — Phone must be at least 8 characters long; Zip must be at least 5 characters long; Facebook is incorrectly formatted… — for the entirely normal case of a customer with no phone number and no Instagram. If you are on an older build, that is the bug, and the workaround is to type a plausible value into each complaining field.

Reusing the email address of a deleted account

Deleting a customer is a soft delete: the row survives with a deletedAt timestamp, and it keeps its claim on the unique index over email. So the address is not free afterwards, and creating a new account with it — through the admin form, the importer or public registration — is refused:

A deleted account still holds this email address. Restore that account, or
delete it permanently, before reusing the address.

Restoring is almost always what you want, because it brings the customer's transactions, tickets and history back with it: DELETE /api/admin/crm/user/{id}?restore=true. A permanent removal is ?force=true, and it takes the history with it.

The availability check ran under the default scope, which hides deleted rows — so it could not see the row that was about to reject the insert. The create went ahead and died on the database constraint, returning a server error naming a SQL index. It was the same problem; it just did not say so.

Everything about how a permission key is built, where it is enforced and why a revocation needs a backend restart lives in Roles and permissions.