TL;DR
Give admins a proactive way to find any account (by name, username, or email) and freeze / unfreeze it directly, plus a paginated admin list of currently frozen accounts, and replace the misleading 404 a frozen profile returns today with a more honest status (proposed: 403 Forbidden for a temporary hold, 410 Gone for a permanent one). Today freezing only happens reactively as a side effect of the report-to-moderation-case flow; there is no admin search, no direct freeze button, and no frozen-accounts list.
Glossary (so this reads standalone)
- Admin: a user with
admin?: true. The whole /admin area is gated by the :admin pipeline (Plugs.RequireLogin + Plugs.AuthAdmin) in lib/vutuv_web/router.ex.
- Freeze (account level): set
users.frozen_at. The account is then hidden from everyone except its owner and admins. This is the moderation "in the freezer pending review" state, not a permanent ban.
- Account moderation states (all on the
User row, lib/vutuv/accounts/user.ex):
frozen_at — profile hidden pending review (reversible). Does not block login.
suspended_until — login blocked and profile hidden until a date (strike 2).
deactivated_at — permanent (strike 3).
- Block (user to user): one member hiding another (
Vutuv.Social.Block). This is unrelated to an admin freeze and is out of scope here.
What exists today (so we reuse, not reinvent)
- The visibility rule is already centralised:
Vutuv.Moderation.account_hidden?/1 and Vutuv.Moderation.profile_visible_to?/2 (lib/vutuv/moderation.ex:940, :952). Owner and admin bypass; everyone else is denied.
- The HTML gate
VutuvWeb.Plug.EnsureActivated (lib/vutuv_web/plugs/ensure_activated.ex) calls that rule and returns 404 today for both never-activated registrations (anti-spam gate) and moderation-hidden accounts. The agent-format siblings (.md/.json/.vcf etc.) get viewer: nil and 404 for everyone, for cache safety.
- Freezing a user account currently only happens reactively: a report escalates into a
Case, and the freeze runs through the private set_frozen_at(%User{}, ...) -> set_user_moderation!/2 (lib/vutuv/moderation.ex:1018). There is no admin-initiated, caseless freeze path. set_user_moderation!/2 is defp.
- Admin area today (
router.ex, scope "/admin"): AdminController.index (a paginated list of identity_verified? != true users), ModerationController (the report queue + per-case uphold/reject), ads review, slugs, tags, exonyms, api_apps suspend. There is no account search and no frozen-accounts list.
- Offset pagination is
Vutuv.Pages.paginate/3 (250 per page) rendered with <.pager params=… total=… />. AdminController.index is the copy-paste template (lib/vutuv_web/controllers/admin/admin_controller.ex).
Where this lives (code map for the implementer)
| Concern |
File |
| Admin routes / pipeline |
lib/vutuv_web/router.ex (scope "/admin", pipeline :admin) |
| Admin index pattern to copy |
lib/vutuv_web/controllers/admin/admin_controller.ex |
| Moderation context (state + rule) |
lib/vutuv/moderation.ex (account_hidden?/1, profile_visible_to?/2, login_block/1, set_user_moderation!/2) |
| Account state fields |
lib/vutuv/accounts/user.ex (frozen_at, suspended_until, deactivated_at) |
| HTTP status gate |
lib/vutuv_web/plugs/ensure_activated.ex |
| Pagination |
Vutuv.Pages.paginate/3 + <.pager> |
| Admin UI table affordances |
<.edit_delete_actions> (the admin/group index tables already use it), .card__tablewrap |
Part 1 — Admin account search + freeze / unfreeze
Add an admin page that lets an admin search for any account and freeze or unfreeze it directly, without waiting for a report.
Search should match (case-insensitive, ILIKE):
- username / active slug (with or without a leading
@),
- any of the account's email addresses,
- the displayed name (first/last).
Action: each result row carries a Freeze button (or Unfreeze when already frozen). This is a state-changing admin POST through the :browser pipeline, so it is CSRF-protected (cover it with submit_with_csrf/3, not a bare post/3, per the CSRF rule in CLAUDE.md). A confirm prompt and an optional free-text reason are nice to have.
+-- /admin/accounts ---------------------------------------------+
| Find an account |
| +-----------------------------------------+ +----------+ |
| | search by name, @username, or email | | Search | |
| +-----------------------------------------+ +----------+ |
| |
| Results (2) |
| +----------------------------------------------------------+ |
| | [av] Erika Mustermann @erika erika@example.com | |
| | active [ Freeze ] | |
| +----------------------------------------------------------+ |
| | [av] Max Mustermann @max max@example.com | |
| | frozen 2026-06-18 [ Unfreeze ] | |
| +----------------------------------------------------------+ |
+----------------------------------------------------------------+
Reuse the existing freeze plumbing. Set frozen_at and let the rest fall out for free: account_hidden?/1 already flips, profile_visible_to?/2 already hides the profile (and every per-user page) from non-admins, and the owner already sees the amber <.frozen_banner>. Do not introduce a parallel "frozen" concept.
Design decision (call out in the PR): the existing freeze always ties to a Case. An admin-initiated freeze has no report and no case. Pick one:
- a) a public
Moderation.admin_freeze_user/2 / admin_unfreeze_user/2 that records a moderation-log entry (preferred: keeps an audit trail without faking a report), or
- b) open a synthetic admin-initiated
Case.
Either way, promote a public, audited entry point rather than calling the private set_user_moderation!/2 from the controller.
Note for the implementer: freezing (frozen_at) hides the profile but does not block login (login_block/1 only fires on suspended_until / deactivated_at). So a frozen member can still sign in and will see their owner banner. If "freeze should also lock the member out" is wanted, that is a separate decision (it would mean setting suspended_until too). Flag it; do not silently change login behaviour.
Part 2 — Paginated list of frozen accounts
A new admin page listing every currently frozen account, newest freeze first, paginated exactly like AdminController.index (Vutuv.Pages.paginate/3, 250/page, <.pager>). Add a link to it from the /admin hub.
+-- /admin/accounts/frozen ------------------------------------------+
| Frozen accounts (42) |
| +-------------+----------+--------------+---------+-------------+ |
| | Member | Username | Frozen since | Source | Action | |
| +-------------+----------+--------------+---------+-------------+ |
| | Max M. | @max | 2026-06-18 | admin | [Unfreeze] | |
| | Spam Bot | @spam99 | 2026-06-17 | report | [Unfreeze] | |
| +-------------+----------+--------------+---------+-------------+ |
| < 1 2 3 ... > (pager, 250/pg) |
+--------------------------------------------------------------------+
Query: from u in User, where: not is_nil(u.frozen_at), order_by: [desc: u.frozen_at, desc: u.id]. "Source" (admin vs report) comes from the audit entry / case linkage chosen in Part 1. Whether to also list suspended_until / deactivated_at accounts here, or keep this strictly the frozen tier, is an open question (a combined "moderated accounts" view with a state column may be more useful).
Part 3 — A proper HTTP status for a frozen profile (404 is wrong)
Current behavior: EnsureActivated returns 404 Not Found for a moderation-hidden account (render_error(conn, 404) at ensure_activated.ex:31).
Why 404 is wrong here: 404 means "this resource does not exist." A frozen account does exist, it is just withheld. A client or crawler cannot tell "never existed" from "temporarily withheld," which is misleading and makes the state un-actionable downstream.
The real trade-off (this is the decision to make, not just a code swap): 404 is also a deliberate privacy / anti-enumeration choice. Returning anything other than 404 confirms the account exists and signals it was moderated. So:
- Keep 404 for the never-activated / spam-gate case. Those accounts must stay indistinguishable from non-existent ones (you must not be able to probe whether an email is registered). This part of
EnsureActivated should not change.
- Change only the moderation-hidden case to a status that says "exists, withheld."
Options considered:
| Code |
Meaning |
Fit for a frozen account |
| 404 Not Found (today) |
does not exist |
wrong: it does exist |
| 403 Forbidden |
understood, refuse to serve to anyone |
good fit for a reversible hold (frozen / suspended) |
| 410 Gone |
existed, permanently removed |
good fit for deactivated_at (permanent) |
| 451 Unavailable For Legal Reasons |
withheld due to a legal demand |
only when the freeze is an actual legal takedown, not a policy/ToS freeze |
| 423 Locked |
WebDAV resource lock |
semantically "frozen" but it is a WebDAV extension with poor general client support; avoid |
Proposed resolution:
GET /:slug
|
v
account exists? --- no ---> 404 Not Found
| yes
v
never activated (spam gate)? --- yes ---> 404 Not Found (keep: must stay
| no indistinguishable)
v
frozen / suspended (hold)? --- yes ---> 403 Forbidden (proposed)
| no
v
deactivated (permanent)? --- yes ---> 410 Gone (proposed)
| no
v
200 OK (render the profile)
- The owner/admin bypass is unchanged: they still get the HTML profile (200).
- Apply the same status to the agent-format siblings so HTML and
.md/.json/.vcf stay consistent (they share EnsureActivated).
- Give 403/410 a clear
.error-page body via VutuvWeb.ErrorHTML ("This profile is currently unavailable.") rather than the generic 404 copy. No need to reveal why it is unavailable.
Acceptance criteria
Suggested routes (not binding)
# scope "/admin", VutuvWeb.Admin, as: :admin
get "/accounts", AccountController, :index # search box + results
get "/accounts/frozen", AccountController, :frozen # paginated frozen list
post "/accounts/:id/freeze", AccountController, :freeze
post "/accounts/:id/unfreeze", AccountController, :unfreeze
(/accounts/frozen must precede /accounts/:id so the literal segment wins, same as the existing /moderation/reporters ordering.)
Out of scope / open questions
- Whether a freeze should also block login (set
suspended_until) or stay profile-hidden only (current behavior). Default: profile-hidden only.
- Whether the list should cover all moderation states (frozen + suspended + deactivated) with a state column, or stay strictly the frozen tier.
- Choosing the exact status pair (403/410 vs. an alternative) is the one product decision left in this issue; the privacy/anti-enumeration constraint on the never-activated case is not negotiable.
TL;DR
Give admins a proactive way to find any account (by name, username, or email) and freeze / unfreeze it directly, plus a paginated admin list of currently frozen accounts, and replace the misleading 404 a frozen profile returns today with a more honest status (proposed: 403 Forbidden for a temporary hold, 410 Gone for a permanent one). Today freezing only happens reactively as a side effect of the report-to-moderation-case flow; there is no admin search, no direct freeze button, and no frozen-accounts list.
Glossary (so this reads standalone)
admin?: true. The whole/adminarea is gated by the:adminpipeline (Plugs.RequireLogin+Plugs.AuthAdmin) inlib/vutuv_web/router.ex.users.frozen_at. The account is then hidden from everyone except its owner and admins. This is the moderation "in the freezer pending review" state, not a permanent ban.Userrow,lib/vutuv/accounts/user.ex):frozen_at— profile hidden pending review (reversible). Does not block login.suspended_until— login blocked and profile hidden until a date (strike 2).deactivated_at— permanent (strike 3).Vutuv.Social.Block). This is unrelated to an admin freeze and is out of scope here.What exists today (so we reuse, not reinvent)
Vutuv.Moderation.account_hidden?/1andVutuv.Moderation.profile_visible_to?/2(lib/vutuv/moderation.ex:940,:952). Owner and admin bypass; everyone else is denied.VutuvWeb.Plug.EnsureActivated(lib/vutuv_web/plugs/ensure_activated.ex) calls that rule and returns 404 today for both never-activated registrations (anti-spam gate) and moderation-hidden accounts. The agent-format siblings (.md/.json/.vcfetc.) getviewer: niland 404 for everyone, for cache safety.Case, and the freeze runs through the privateset_frozen_at(%User{}, ...)->set_user_moderation!/2(lib/vutuv/moderation.ex:1018). There is no admin-initiated, caseless freeze path.set_user_moderation!/2isdefp.router.ex,scope "/admin"):AdminController.index(a paginated list ofidentity_verified? != trueusers),ModerationController(the report queue + per-case uphold/reject), ads review, slugs, tags, exonyms, api_apps suspend. There is no account search and no frozen-accounts list.Vutuv.Pages.paginate/3(250 per page) rendered with<.pager params=… total=… />.AdminController.indexis the copy-paste template (lib/vutuv_web/controllers/admin/admin_controller.ex).Where this lives (code map for the implementer)
lib/vutuv_web/router.ex(scope "/admin",pipeline :admin)lib/vutuv_web/controllers/admin/admin_controller.exlib/vutuv/moderation.ex(account_hidden?/1,profile_visible_to?/2,login_block/1,set_user_moderation!/2)lib/vutuv/accounts/user.ex(frozen_at,suspended_until,deactivated_at)lib/vutuv_web/plugs/ensure_activated.exVutuv.Pages.paginate/3+<.pager><.edit_delete_actions>(the admin/group index tables already use it),.card__tablewrapPart 1 — Admin account search + freeze / unfreeze
Add an admin page that lets an admin search for any account and freeze or unfreeze it directly, without waiting for a report.
Search should match (case-insensitive,
ILIKE):@),Action: each result row carries a Freeze button (or Unfreeze when already frozen). This is a state-changing admin
POSTthrough the:browserpipeline, so it is CSRF-protected (cover it withsubmit_with_csrf/3, not a barepost/3, per the CSRF rule inCLAUDE.md). A confirm prompt and an optional free-text reason are nice to have.Reuse the existing freeze plumbing. Set
frozen_atand let the rest fall out for free:account_hidden?/1already flips,profile_visible_to?/2already hides the profile (and every per-user page) from non-admins, and the owner already sees the amber<.frozen_banner>. Do not introduce a parallel "frozen" concept.Design decision (call out in the PR): the existing freeze always ties to a
Case. An admin-initiated freeze has no report and no case. Pick one:Moderation.admin_freeze_user/2/admin_unfreeze_user/2that records a moderation-log entry (preferred: keeps an audit trail without faking a report), orCase.Either way, promote a public, audited entry point rather than calling the private
set_user_moderation!/2from the controller.Note for the implementer: freezing (
frozen_at) hides the profile but does not block login (login_block/1only fires onsuspended_until/deactivated_at). So a frozen member can still sign in and will see their owner banner. If "freeze should also lock the member out" is wanted, that is a separate decision (it would mean settingsuspended_untiltoo). Flag it; do not silently change login behaviour.Part 2 — Paginated list of frozen accounts
A new admin page listing every currently frozen account, newest freeze first, paginated exactly like
AdminController.index(Vutuv.Pages.paginate/3, 250/page,<.pager>). Add a link to it from the/adminhub.Query:
from u in User, where: not is_nil(u.frozen_at), order_by: [desc: u.frozen_at, desc: u.id]. "Source" (admin vs report) comes from the audit entry / case linkage chosen in Part 1. Whether to also listsuspended_until/deactivated_ataccounts here, or keep this strictly the frozen tier, is an open question (a combined "moderated accounts" view with a state column may be more useful).Part 3 — A proper HTTP status for a frozen profile (404 is wrong)
Current behavior:
EnsureActivatedreturns 404 Not Found for a moderation-hidden account (render_error(conn, 404)atensure_activated.ex:31).Why 404 is wrong here: 404 means "this resource does not exist." A frozen account does exist, it is just withheld. A client or crawler cannot tell "never existed" from "temporarily withheld," which is misleading and makes the state un-actionable downstream.
The real trade-off (this is the decision to make, not just a code swap): 404 is also a deliberate privacy / anti-enumeration choice. Returning anything other than 404 confirms the account exists and signals it was moderated. So:
EnsureActivatedshould not change.Options considered:
deactivated_at(permanent)Proposed resolution:
.md/.json/.vcfstay consistent (they shareEnsureActivated)..error-pagebody viaVutuvWeb.ErrorHTML("This profile is currently unavailable.") rather than the generic 404 copy. No need to reveal why it is unavailable.Acceptance criteria
@username/slug, and email from a new/adminpage.Moderationentry point (not the privateset_user_moderation!/2)./adminlist of currently frozen accounts (newest first,<.pager>), linked from the admin hub.CLAUDE.md): a controller test for search + freeze/unfreeze (CSRF viasubmit_with_csrf/3), anEnsureActivatedstatus test per state, and a frozen-list pagination test. Smoke-test the freeze flow in a real browser before deploy.Suggested routes (not binding)
(
/accounts/frozenmust precede/accounts/:idso the literal segment wins, same as the existing/moderation/reportersordering.)Out of scope / open questions
suspended_until) or stay profile-hidden only (current behavior). Default: profile-hidden only.