Skip to content

Feature Request: Admin can search any account and freeze it, with a paginated frozen-accounts list and a proper HTTP status (not 404) #812

Description

@wintermeyer

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

  • Admin can search accounts by name, @username/slug, and email from a new /admin page.
  • Admin can freeze and unfreeze any account from the search results, through a CSRF-protected POST and a public, audited Moderation entry point (not the private set_user_moderation!/2).
  • A paginated /admin list of currently frozen accounts (newest first, <.pager>), linked from the admin hub.
  • A frozen/suspended profile returns 403 (proposed) and a deactivated one 410 (proposed) to non-owners, while never-activated accounts keep returning 404; owner/admin still see the profile.
  • HTML and agent-format siblings return the same status.
  • Tests first (per CLAUDE.md): a controller test for search + freeze/unfreeze (CSRF via submit_with_csrf/3), an EnsureActivated status test per state, and a frozen-list pagination test. Smoke-test the freeze flow in a real browser before deploy.

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.

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions