Platform admin
Users (incl. delete)
/admin/users lists every user account on the platform —
super-admins, customers, and members invited into any workspace. The
page supports search by name/email, role promotion, BYOK per-user
overrides, edit, impersonate, and (since v1.1) delete.
Who can open /admin
Only super-admins. Everyone else gets a plain 404 Not Found,
signed in or not, so the area is not advertised to customers
(EnsureSuperAdmin). A super-admin who is impersonating a
customer still gets through. If the platform owner sees that 404, the
browser is signed into another account: a test customer account created
a moment ago sees it too. Sign out and sign back in with the super-admin
account, or keep test accounts in a private window.
Sign-up, the email-verification link, login and the two-factor step send
a user back to the page they opened before signing in. When that page is
under /admin and the user is not a super-admin, it is dropped
and they land on the dashboard instead (#720,
DropsAdminIntendedUrl). Before that, a customer who had
opened /admin/users while signed out landed on the 404 right
after confirming their email. Super-admins still return to the admin page
they asked for, and every other remembered page, such as an invitation
link, is honoured as before.
Delete
Super-admin only. Click the Delete button on any user row (the button is hidden on your own row). A confirm dialog spells out the consequence — they lose access to every workspace and the row is soft-deleted for audit.
Safety rails
The controller refuses four cases:
- Self-delete — you cannot delete the account you are signed into. Would lock you out of admin.
- Last super-admin — defence-in-depth check; the controller refuses if the target is a super-admin and no other live super-admin exists.
-
Workspace owner — if the user still owns one or
more workspaces, the delete returns an error toast naming the
count (its text says "Transfer ownership first"). There is no
ownership transfer anywhere in the app: neither
/admin/workspaces/{id}nor the workspace's own Team page can change the owner. The only way through is to delete those workspaces first (from/admin/workspaces) and retry. -
Non-super-admin actor — the route is in the
platform-admin group; a customer hitting
DELETE /admin/users/{id}directly returns 404 to hide the endpoint's existence.
What soft-delete touches
-
users.deleted_atis set to the current timestamp. Subsequent reads via Eloquent's default scope exclude the row. -
The address is released: the row's email becomes
<email>#deleted-<id>(User::releasedEmail(), fired by the model's soft-delete event, so single and bulk delete both do it). The same address can sign up again, or be given to another account, right away. The#after the domain makes the marker an invalid address, so nobody can sign up with the marker itself. Before #719 the row kept the bare address: sign-up answered "The email has already been taken", theUNIQUEindex would have refused a second row anyway, and login ignores deleted accounts, so the address was locked for good. The migration2026_10_06_170000_release_emails_of_deleted_usersreleased the rows deleted before that. -
All
workspace_userspivot rows for the user are deleted so a re-invited account doesn't collide on the unique composite key. -
Foreign-key references in
conversations,leads,audit_logs, and other tables with auser_idcolumn are left intact — soft-delete preserves the row, so those joins keep working. -
An
AuditLogentry withaction=platform_user.deletedis written under the acting admin's default workspace, including the deleted email (as it was, without the marker) + role for forensic recovery.
Customer-initiated deletion (different code path)
When a customer deletes their own account from
/settings/profile, the row is
hard-deleted via forceDelete() — a
customer-initiated deletion is treated as a GDPR right-to-erasure
request, so the data goes away for real. Only the platform-admin
delete is soft.
Restoring a soft-deleted user
There is no admin UI for restore yet. A deleted account carries the marker, so look it up by it and put the address back after restoring:
php artisan tinker --execute '$u = App\Models\User::withTrashed()->where("email", "like", "x@y.com#deleted-%")->first(); $u->restore(); $u->forceFill(["email" => "x@y.com"])->save();'
If someone has signed up with that address since, the last step fails
on the UNIQUE index: the address belongs to the new
account now.
The user's workspace memberships are NOT restored — they need a
fresh invitation. If the original workspace is gone too,
default_workspace_id will be stale and the next visit
redirects them through the standard onboarding flow.