From f1322dd92b2844c0659ffe75145b21fc28717762 Mon Sep 17 00:00:00 2001 From: Darwin D Wu Date: Wed, 26 Aug 2026 10:24:32 -0700 Subject: [PATCH 1/3] feat: add platform moderation actions Implement reasoned, recently reauthenticated paste and account moderation in the platform administration panel. Administrative paste removal now expires content and appends its audit event atomically before retryable storage cleanup, preserving the moderation decision through storage outages. --- lib/textbin/administration.ex | 81 +++++++++++++++++ lib/textbin_web/live/ui/admin_live.ex | 91 +++++++++++++++++++ .../live/ui/admin_live/index.html.heex | 90 ++++++++++++++++++ rfd/0001/IMPLEMENTATION.org | 4 +- test/textbin/administration_test.exs | 81 +++++++++++++++++ test/textbin_web/live/ui/admin_live_test.exs | 75 +++++++++++++++ 6 files changed, 420 insertions(+), 2 deletions(-) diff --git a/lib/textbin/administration.ex b/lib/textbin/administration.ex index 993a4bc..e6a5671 100644 --- a/lib/textbin/administration.ex +++ b/lib/textbin/administration.ex @@ -320,6 +320,22 @@ defmodule Textbin.Administration do end end + @doc """ + Makes a paste immediately inaccessible and records the moderation decision. + + Blob removal and hard deletion remain the responsibility of the retryable + expiration cleaner, so storage availability cannot roll back moderation. + """ + def delete_paste(scope, paste, reason, opts \\ []) do + with {:ok, reason} <- normalize_reason(reason), + {:ok, paste_id} <- paste_id(paste) do + authority_transaction( + scope, + &delete_paste_in_transaction(&1, paste_id, reason, opts) + ) + end + end + defp authorize_account_deletion_in_transaction(scope, user_id) do with :ok <- lock_authority_changes(), %User{suspended_at: nil} = user <- lock_user(user_id), @@ -485,6 +501,30 @@ defmodule Textbin.Administration do end end + defp delete_paste_in_transaction(actor, paste_id, reason, opts) do + now = Paste.utc_now_ms() + + with %Paste{} = paste <- lock_active_paste(paste_id, now), + {:ok, expired_paste} <- + paste + |> Ecto.Changeset.change(expires_at: now) + |> Repo.update(), + :ok <- + record_paste_audit( + actor, + "platform.paste.deleted", + expired_paste, + reason, + %{"previous_expires_at" => encode_timestamp(paste.expires_at)}, + opts + ) do + {:ok, expired_paste} + else + nil -> {:error, :not_found} + error -> error + end + end + defp authority_transaction(%Scope{} = scope, callback) do Repo.transact(fn -> with :ok <- lock_authority_changes(), @@ -617,6 +657,23 @@ defmodule Textbin.Administration do |> audit_result() end + defp record_paste_audit(actor, action, target, reason, metadata, opts) do + %PlatformAuditEvent{} + |> PlatformAuditEvent.changeset(%{ + actor_kind: "user", + actor_user_id: actor.id, + actor_label: actor.email, + action: action, + target_type: "paste", + target_id: target.id, + reason: reason, + request_id: Keyword.get(opts, :request_id), + metadata: metadata + }) + |> Repo.insert() + |> audit_result() + end + defp audit_result({:ok, %PlatformAuditEvent{}}), do: :ok defp audit_result({:error, changeset}), do: {:error, changeset} @@ -637,6 +694,16 @@ defmodule Textbin.Administration do Repo.one(from user in User, where: user.email == ^email, lock: "FOR UPDATE") end + defp lock_active_paste(paste_id, now) do + Repo.one( + from paste in Paste, + where: + paste.id == ^paste_id and + (is_nil(paste.expires_at) or paste.expires_at > ^now), + lock: "FOR UPDATE" + ) + end + defp normalize_reason(reason) when is_binary(reason) do case String.trim(reason) do "" -> {:error, :reason_required} @@ -658,6 +725,20 @@ defmodule Textbin.Administration do defp user_id(_id), do: {:error, :not_found} + defp paste_id(%Paste{id: id}), do: paste_id(id) + + defp paste_id(id) when is_binary(id) do + case Ecto.UUID.cast(id) do + {:ok, id} -> {:ok, id} + :error -> {:error, :not_found} + end + end + + defp paste_id(_id), do: {:error, :not_found} + + defp encode_timestamp(nil), do: nil + defp encode_timestamp(timestamp), do: DateTime.to_iso8601(timestamp) + defp distinct_users(id, id), do: {:error, :same_user} defp distinct_users(_target_id, _replacement_id), do: :ok diff --git a/lib/textbin_web/live/ui/admin_live.ex b/lib/textbin_web/live/ui/admin_live.ex index 2550356..d98eb19 100644 --- a/lib/textbin_web/live/ui/admin_live.ex +++ b/lib/textbin_web/live/ui/admin_live.ex @@ -18,6 +18,8 @@ defmodule TextbinWeb.UI.AdminLive do socket |> assign(:page_title, "Platform administration") |> assign(:lookup_form, to_form(%{"query" => ""}, as: :lookup)) + |> assign(:moderation_form, to_form(%{"paste_id" => "", "reason" => ""}, as: :moderation)) + |> assign(:account_action_form, to_form(%{"reason" => ""}, as: :account_action)) |> assign(:lookup_performed?, false) |> assign(:lookup, empty_lookup()) |> stream_configure(:largest_pastes, dom_id: &"largest-paste-row-#{&1.row_key}")} @@ -71,6 +73,31 @@ defmodule TextbinWeb.UI.AdminLive do end end + def handle_event( + "moderate_paste", + %{"moderation" => %{"paste_id" => paste_id, "reason" => reason}}, + socket + ) do + socket.assigns.current_scope + |> Administration.delete_paste(paste_id, reason) + |> handle_mutation_result(socket, "Paste removed and queued for storage cleanup.") + end + + def handle_event( + "account_action", + %{ + "account_action" => %{ + "action" => action, + "target_id" => target_id, + "reason" => reason + } + }, + socket + ) do + result = account_action(action, socket.assigns.current_scope, target_id, reason) + handle_mutation_result(result, socket, account_action_message(action)) + end + def handle_event("load_more_audit", _params, %{assigns: %{audit_next_cursor: nil}} = socket), do: {:noreply, socket} @@ -99,6 +126,61 @@ defmodule TextbinWeb.UI.AdminLive do |> push_navigate(to: ~p"/") end + defp account_action("grant", scope, target_id, reason), + do: Administration.grant_platform_admin(scope, target_id, reason) + + defp account_action("revoke", scope, target_id, reason), + do: Administration.revoke_platform_admin(scope, target_id, reason) + + defp account_action("suspend", scope, target_id, reason), + do: Administration.suspend_user(scope, target_id, reason) + + defp account_action("restore", scope, target_id, reason), + do: Administration.restore_user(scope, target_id, reason) + + defp account_action(_action, _scope, _target_id, _reason), do: {:error, :not_found} + + defp account_action_message("grant"), do: "Platform administrator access granted." + defp account_action_message("revoke"), do: "Platform administrator access revoked." + defp account_action_message("suspend"), do: "Account suspended and active sessions revoked." + defp account_action_message("restore"), do: "Account restored. A new login is still required." + defp account_action_message(_action), do: "Account updated." + + defp handle_mutation_result({:ok, _result}, socket, message) do + {:noreply, + socket + |> put_flash(:info, message) + |> push_patch(to: ~p"/admin")} + end + + defp handle_mutation_result({:error, :forbidden}, socket, _message), + do: {:noreply, leave_admin(socket)} + + defp handle_mutation_result({:error, :reauthentication_required}, socket, _message) do + {:noreply, + socket + |> put_flash(:error, "Reauthenticate before performing this sensitive action.") + |> push_navigate(to: ~p"/users/log-in")} + end + + defp handle_mutation_result({:error, reason}, socket, _message) do + {:noreply, put_flash(socket, :error, mutation_error(reason))} + end + + defp mutation_error(:reason_required), do: "A reason is required." + defp mutation_error(:reason_too_long), do: "The reason must be at most 500 bytes." + defp mutation_error(:not_found), do: "The target is unavailable or has already been handled." + + defp mutation_error(:final_active_admin), + do: "The final active administrator cannot be removed." + + defp mutation_error(:self_suspension), do: "You cannot suspend your own account." + defp mutation_error(:already_suspended), do: "That account is already suspended." + defp mutation_error(:unconfirmed), do: "Only confirmed accounts can become administrators." + defp mutation_error(:suspended), do: "A suspended account cannot become an administrator." + defp mutation_error(:ineligible), do: "That account is not eligible for this action." + defp mutation_error(_reason), do: "The administrative action could not be completed." + defp empty_lookup, do: %{user: nil, organization: nil, workspace: nil} defp largest_stream_entries(page) do @@ -130,12 +212,21 @@ defmodule TextbinWeb.UI.AdminLive do def status_class("Active"), do: "bg-success/10 text-success" def status_class(_status), do: "bg-warning/10 text-warning" + def account_action_options(%{suspended_at: %DateTime{}}), do: [{"Restore account", "restore"}] + + def account_action_options(%{platform_role: "admin"}), + do: [{"Revoke platform administrator", "revoke"}, {"Suspend account", "suspend"}] + + def account_action_options(_user), + do: [{"Grant platform administrator", "grant"}, {"Suspend account", "suspend"}] + def audit_title("platform.admin.bootstrap"), do: "Platform administrator bootstrapped" def audit_title("platform.admin.granted"), do: "Platform administrator granted" def audit_title("platform.admin.revoked"), do: "Platform administrator revoked" def audit_title("platform.account.suspended"), do: "Account suspended" def audit_title("platform.account.restored"), do: "Account restored" def audit_title("platform.admin.account_deleted"), do: "Administrator account deleted" + def audit_title("platform.paste.deleted"), do: "Paste administratively removed" def audit_title(action), do: action def page_params(kind, page, assigns) do diff --git a/lib/textbin_web/live/ui/admin_live/index.html.heex b/lib/textbin_web/live/ui/admin_live/index.html.heex index 70bda3a..eb65223 100644 --- a/lib/textbin_web/live/ui/admin_live/index.html.heex +++ b/lib/textbin_web/live/ui/admin_live/index.html.heex @@ -129,6 +129,43 @@ <.summary_stat label="Workspaces" value={@lookup.user.workspace_memberships} /> <.summary_stat label="Pastes" value={@lookup.user.pastes} /> + <.form + for={@account_action_form} + id="admin-account-action-form" + phx-submit="account_action" + class="mt-5 grid gap-3 border-t border-base-300 pt-5 sm:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)_auto] sm:items-end" + > + <.input + field={@account_action_form[:action]} + id="admin-account-action" + type="select" + label="Action" + options={account_action_options(@lookup.user)} + required + /> + <.input + field={@account_action_form[:reason]} + id="admin-account-action-reason" + type="text" + label="Reason" + maxlength="500" + required + /> + + +
+
+
+
+
+ <.icon name="hero-shield-exclamation" class="size-5" /> +
+

Moderation

+

Remove a paste

+

+ The paste becomes inaccessible immediately. Blob cleanup is retried independently if storage is unavailable. +

+
+ <.form + for={@moderation_form} + id="admin-paste-moderation-form" + phx-submit="moderate_paste" + class="grid content-start gap-4" + > + <.input + field={@moderation_form[:paste_id]} + id="admin-moderation-paste-id" + type="text" + label="Exact paste ID" + placeholder="Paste UUID" + autocomplete="off" + required + /> + <.input + field={@moderation_form[:reason]} + id="admin-moderation-reason" + type="textarea" + label="Moderation reason" + maxlength="500" + required + /> +
+

Recent reauthentication is required.

+ +
+ +
+
+
<.paste_panel id="recent-public-pastes" diff --git a/rfd/0001/IMPLEMENTATION.org b/rfd/0001/IMPLEMENTATION.org index 7c9d58c..aab4a5a 100644 --- a/rfd/0001/IMPLEMENTATION.org +++ b/rfd/0001/IMPLEMENTATION.org @@ -49,9 +49,9 @@ Add reasoned, reauthenticated mutations after authorization, auditing, and read-only inspection are in place. Report review starts after RFD 4 provides the report model. -- [ ] Administrative paste deletion makes content inaccessible before retryable +- [X] Administrative paste deletion makes content inaccessible before retryable storage cleanup and remains audited when storage is unavailable. -- [ ] Sensitive actions enforce the documented reason and recent-reauthentication +- [X] Sensitive actions enforce the documented reason and recent-reauthentication matrix. - [ ] Once the RFD 4 report model exists, platform administrators can page through the report queue and dismiss or resolve reports with a reason and audit event. diff --git a/test/textbin/administration_test.exs b/test/textbin/administration_test.exs index 8442680..3dfcbb6 100644 --- a/test/textbin/administration_test.exs +++ b/test/textbin/administration_test.exs @@ -333,6 +333,87 @@ defmodule Textbin.AdministrationTest do end end + describe "administrative paste deletion" do + setup do + admin = admin_fixture() + owner = user_fixture() + owner_scope = user_scope_fixture(owner) + + %{admin: admin, scope: admin_scope(admin), owner_scope: owner_scope} + end + + test "expires and audits the paste before retryable storage cleanup", context do + original_storage = Application.fetch_env!(:textbin, Textbin.Storage) + + on_exit(fn -> Application.put_env(:textbin, Textbin.Storage, original_storage) end) + + assert {:ok, paste} = + Pastes.create_paste(context.owner_scope, %{ + data: String.duplicate("moderated", 1_024), + audience: "public" + }) + + Application.put_env(:textbin, Textbin.Storage, + adapter: Textbin.FailingDeleteStorage, + opts: [test_pid: self(), delegate: original_storage] + ) + + assert {:ok, %Paste{expires_at: %DateTime{}}} = + Administration.delete_paste(context.scope, paste.id, "malware distribution", + request_id: "request-456" + ) + + refute_received {:storage_delete_failed, _storage_key} + refute Pastes.get_shared_paste(nil, paste.id) + assert Repo.get(Paste, paste.id) + + assert %PlatformAuditEvent{ + action: "platform.paste.deleted", + target_type: "paste", + target_id: target_id, + reason: "malware distribution", + request_id: "request-456" + } = + Repo.one!( + from event in PlatformAuditEvent, + where: event.action == "platform.paste.deleted" + ) + + assert target_id == paste.id + assert Pastes.delete_expired_pastes(limit: 1) == 0 + assert_receive {:storage_delete_failed, storage_key} + assert storage_key == paste.storage_key + assert Repo.get(Paste, paste.id) + end + + test "requires a reason, recent reauthentication, and current authority", context do + assert {:ok, paste} = + Pastes.create_paste(context.owner_scope, %{data: "reported", audience: "public"}) + + assert {:error, :reason_required} = + Administration.delete_paste(context.scope, paste.id, " ") + + stale_scope = + Scope.for_user(%{ + context.admin + | authenticated_at: DateTime.add(DateTime.utc_now(:second), -21, :minute) + }) + + assert {:error, :reauthentication_required} = + Administration.delete_paste(stale_scope, paste.id, "policy violation") + + assert {:error, :forbidden} = + Administration.delete_paste(context.owner_scope, paste.id, "not authorized") + + assert Repo.get!(Paste, paste.id).expires_at == nil + + refute Repo.exists?( + from event in PlatformAuditEvent, + where: event.action == "platform.paste.deleted" + ) + end + end + describe "administration reads" do setup do admin = admin_fixture() diff --git a/test/textbin_web/live/ui/admin_live_test.exs b/test/textbin_web/live/ui/admin_live_test.exs index d59a694..38cc874 100644 --- a/test/textbin_web/live/ui/admin_live_test.exs +++ b/test/textbin_web/live/ui/admin_live_test.exs @@ -1,12 +1,16 @@ defmodule TextbinWeb.UI.AdminLiveTest do use TextbinWeb.ConnCase, async: false + import Ecto.Query import Phoenix.LiveViewTest import Textbin.AccountsFixtures alias Textbin.Accounts.Scope + alias Textbin.Accounts.User alias Textbin.Administration + alias Textbin.Administration.PlatformAuditEvent alias Textbin.Pastes + alias Textbin.Pastes.Paste alias Textbin.Repo alias TextbinWeb.ForbiddenError @@ -70,6 +74,77 @@ defmodule TextbinWeb.UI.AdminLiveTest do assert has_element?(view, "#admin-lookup-empty") end + test "performs reasoned account actions from an exact user lookup", %{conn: conn} do + target = user_fixture() + assert {:ok, view, _html} = live(conn, ~p"/admin") + + view + |> form("#admin-lookup-form", lookup: %{query: target.email}) + |> render_submit() + + assert has_element?(view, "#admin-account-action-form") + + view + |> form("#admin-account-action-form", + account_action: %{ + action: "grant", + target_id: target.id, + reason: "incident response coverage" + } + ) + |> render_submit() + + assert_patch(view, ~p"/admin") + assert Repo.get!(User, target.id).platform_role == "admin" + end + + test "removes a paste immediately and exposes its audit event", %{conn: conn} do + owner = user_fixture() + + assert {:ok, paste} = + Pastes.create_paste(Scope.for_user(owner), %{ + data: "reported content", + audience: "public" + }) + + assert {:ok, view, _html} = live(conn, ~p"/admin") + assert has_element?(view, "#admin-paste-moderation-form") + + view + |> form("#admin-paste-moderation-form", + moderation: %{paste_id: paste.id, reason: "reported malware"} + ) + |> render_submit() + + assert_patch(view, ~p"/admin") + assert %Paste{expires_at: %DateTime{}} = Repo.get!(Paste, paste.id) + + assert Repo.exists?( + from event in PlatformAuditEvent, + where: + event.action == "platform.paste.deleted" and event.target_id == ^paste.id and + event.reason == "reported malware" + ) + end + + test "sensitive panel actions redirect stale sessions to reauthentication", %{conn: conn} do + token = get_session(conn, :user_token) + override_token_authenticated_at(token, DateTime.add(DateTime.utc_now(:second), -21, :minute)) + + owner = user_fixture() + assert {:ok, paste} = Pastes.create_paste(Scope.for_user(owner), %{data: "reported"}) + assert {:ok, view, _html} = live(conn, ~p"/admin") + + view + |> form("#admin-paste-moderation-form", + moderation: %{paste_id: paste.id, reason: "policy violation"} + ) + |> render_submit() + + assert_redirect(view, ~p"/users/log-in") + assert Repo.get!(Paste, paste.id).expires_at == nil + end + test "renders every protected largest-paste row without exposing capability IDs", %{conn: conn} do owner = user_fixture() scope = Scope.for_user(owner) From 89e168e3b436c6a1188767a9c4a49cc9e68a99bc Mon Sep 17 00:00:00 2001 From: Darwin D Wu Date: Wed, 26 Aug 2026 11:47:52 -0700 Subject: [PATCH 2/3] fix: preserve administration data integrity Backfill legacy inline paste hashes alongside sizes so integrity checks continue to pass after migration. Keep the structure snapshot aligned with the audit migration's text reason column and cover both regressions. --- ...90000_add_administration_paste_indexes.exs | 7 ++++- priv/repo/structure.sql | 2 +- .../textbin/administration/migration_test.exs | 28 ++++++++++++++++--- 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/priv/repo/migrations/20260826090000_add_administration_paste_indexes.exs b/priv/repo/migrations/20260826090000_add_administration_paste_indexes.exs index 49d2a7e..0b81ebe 100644 --- a/priv/repo/migrations/20260826090000_add_administration_paste_indexes.exs +++ b/priv/repo/migrations/20260826090000_add_administration_paste_indexes.exs @@ -5,7 +5,12 @@ defmodule Textbin.Repo.Migrations.AddAdministrationPasteIndexes do def change do execute( - "UPDATE pastes SET size_bytes = octet_length(data) WHERE size_bytes IS NULL AND data IS NOT NULL", + """ + UPDATE pastes + SET size_bytes = octet_length(data), + sha256 = sha256(convert_to(data, 'UTF8')) + WHERE data IS NOT NULL AND (size_bytes IS NULL OR sha256 IS NULL) + """, "SELECT 1" ) diff --git a/priv/repo/structure.sql b/priv/repo/structure.sql index 59ba895..77d5afd 100644 --- a/priv/repo/structure.sql +++ b/priv/repo/structure.sql @@ -145,7 +145,7 @@ CREATE TABLE public.platform_audit_events ( action character varying(255) NOT NULL, target_type character varying(255) NOT NULL, target_id uuid NOT NULL, - reason character varying(255) NOT NULL, + reason text NOT NULL, request_id character varying(255), metadata jsonb DEFAULT '{}'::jsonb NOT NULL, inserted_at timestamp without time zone NOT NULL, diff --git a/test/textbin/administration/migration_test.exs b/test/textbin/administration/migration_test.exs index 11879d2..93e4617 100644 --- a/test/textbin/administration/migration_test.exs +++ b/test/textbin/administration/migration_test.exs @@ -2,6 +2,8 @@ defmodule Textbin.Administration.MigrationTest do use ExUnit.Case, async: false alias Textbin.MigrationRepo + alias Textbin.Pastes + alias Textbin.Pastes.Paste @foundation_version 20_260_822_090_000 @administration_indexes_version 20_260_826_090_000 @@ -32,10 +34,22 @@ defmodule Textbin.Administration.MigrationTest do paste_id = insert_legacy_inline_paste() Ecto.Migrator.run(MigrationRepo, migrations, :up, to: @administration_indexes_version) - assert %{rows: [[21]]} = - MigrationRepo.query!("SELECT size_bytes FROM pastes WHERE id = $1::uuid", [ - uuid(paste_id) - ]) + assert %{rows: [["legacy inline content", 21, sha256]]} = + MigrationRepo.query!( + "SELECT data, size_bytes, sha256 FROM pastes WHERE id = $1::uuid", + [ + uuid(paste_id) + ] + ) + + assert sha256 == :crypto.hash(:sha256, "legacy inline content") + + assert %Paste{data: "legacy inline content"} = + Pastes.load_data(%Paste{ + data: "legacy inline content", + size_bytes: 21, + sha256: sha256 + }) assert index_definition("pastes_admin_recent_visibility_index") =~ "(visibility, inserted_at DESC, id DESC)" @@ -48,6 +62,12 @@ defmodule Textbin.Administration.MigrationTest do assert apply(migration, :__migration__, [])[:disable_ddl_transaction] end + test "structure snapshot preserves the audit reason text column" do + structure = File.read!(Path.expand("../../../priv/repo/structure.sql", __DIR__)) + + assert structure =~ ~r/CREATE TABLE public\.platform_audit_events \(.+reason text NOT NULL/s + end + defp insert_legacy_inline_paste do user_id = Ecto.UUID.generate() organization_id = Ecto.UUID.generate() From b2ebcd250531c5e1b065a6958843fb7ff240a9e3 Mon Sep 17 00:00:00 2001 From: Darwin D Wu Date: Wed, 26 Aug 2026 11:48:01 -0700 Subject: [PATCH 3/3] fix: guard platform account actions Hide ineligible platform account actions, prevent self-suspension from being offered, and clear stale lookup controls after successful mutations. Add LiveView regression coverage for actor and target eligibility. --- lib/textbin_web/live/ui/admin_live.ex | 26 ++++++++++++++++--- .../live/ui/admin_live/index.html.heex | 2 +- test/textbin_web/live/ui/admin_live_test.exs | 24 +++++++++++++++++ 3 files changed, 47 insertions(+), 5 deletions(-) diff --git a/lib/textbin_web/live/ui/admin_live.ex b/lib/textbin_web/live/ui/admin_live.ex index d98eb19..ed5eb87 100644 --- a/lib/textbin_web/live/ui/admin_live.ex +++ b/lib/textbin_web/live/ui/admin_live.ex @@ -149,6 +149,7 @@ defmodule TextbinWeb.UI.AdminLive do defp handle_mutation_result({:ok, _result}, socket, message) do {:noreply, socket + |> clear_lookup() |> put_flash(:info, message) |> push_patch(to: ~p"/admin")} end @@ -181,6 +182,14 @@ defmodule TextbinWeb.UI.AdminLive do defp mutation_error(:ineligible), do: "That account is not eligible for this action." defp mutation_error(_reason), do: "The administrative action could not be completed." + defp clear_lookup(socket) do + socket + |> assign(:lookup_form, to_form(%{"query" => ""}, as: :lookup)) + |> assign(:account_action_form, to_form(%{"reason" => ""}, as: :account_action)) + |> assign(:lookup_performed?, false) + |> assign(:lookup, empty_lookup()) + end + defp empty_lookup, do: %{user: nil, organization: nil, workspace: nil} defp largest_stream_entries(page) do @@ -212,13 +221,22 @@ defmodule TextbinWeb.UI.AdminLive do def status_class("Active"), do: "bg-success/10 text-success" def status_class(_status), do: "bg-warning/10 text-warning" - def account_action_options(%{suspended_at: %DateTime{}}), do: [{"Restore account", "restore"}] + def account_action_options(_actor_id, %{suspended_at: %DateTime{}}), + do: [{"Restore account", "restore"}] - def account_action_options(%{platform_role: "admin"}), + def account_action_options(actor_id, %{id: actor_id, platform_role: "admin"}), + do: [{"Revoke platform administrator", "revoke"}] + + def account_action_options(_actor_id, %{platform_role: "admin"}), do: [{"Revoke platform administrator", "revoke"}, {"Suspend account", "suspend"}] - def account_action_options(_user), - do: [{"Grant platform administrator", "grant"}, {"Suspend account", "suspend"}] + def account_action_options(_actor_id, %{ + kind: "registered", + confirmed_at: %DateTime{} + }), + do: [{"Grant platform administrator", "grant"}, {"Suspend account", "suspend"}] + + def account_action_options(_actor_id, _user), do: [{"Suspend account", "suspend"}] def audit_title("platform.admin.bootstrap"), do: "Platform administrator bootstrapped" def audit_title("platform.admin.granted"), do: "Platform administrator granted" diff --git a/lib/textbin_web/live/ui/admin_live/index.html.heex b/lib/textbin_web/live/ui/admin_live/index.html.heex index eb65223..9006e53 100644 --- a/lib/textbin_web/live/ui/admin_live/index.html.heex +++ b/lib/textbin_web/live/ui/admin_live/index.html.heex @@ -140,7 +140,7 @@ id="admin-account-action" type="select" label="Action" - options={account_action_options(@lookup.user)} + options={account_action_options(@current_scope.user.id, @lookup.user)} required /> <.input diff --git a/test/textbin_web/live/ui/admin_live_test.exs b/test/textbin_web/live/ui/admin_live_test.exs index 38cc874..d9bb008 100644 --- a/test/textbin_web/live/ui/admin_live_test.exs +++ b/test/textbin_web/live/ui/admin_live_test.exs @@ -96,6 +96,30 @@ defmodule TextbinWeb.UI.AdminLiveTest do assert_patch(view, ~p"/admin") assert Repo.get!(User, target.id).platform_role == "admin" + refute has_element?(view, "#admin-user-result") + refute has_element?(view, "#admin-account-action-form") + end + + test "offers only eligible account actions", %{admin: admin, conn: conn} do + unconfirmed = unconfirmed_user_fixture() + assert {:ok, guest} = Textbin.Accounts.create_guest_user() + assert {:ok, view, _html} = live(conn, ~p"/admin") + + view + |> form("#admin-lookup-form", lookup: %{query: admin.email}) + |> render_submit() + + assert has_element?(view, "#admin-account-action option[value='revoke']") + refute has_element?(view, "#admin-account-action option[value='suspend']") + + for target <- [unconfirmed, guest] do + view + |> form("#admin-lookup-form", lookup: %{query: target.email}) + |> render_submit() + + assert has_element?(view, "#admin-account-action option[value='suspend']") + refute has_element?(view, "#admin-account-action option[value='grant']") + end end test "removes a paste immediately and exposes its audit event", %{conn: conn} do