diff --git a/config/config.exs b/config/config.exs index 698327d..b898966 100644 --- a/config/config.exs +++ b/config/config.exs @@ -35,6 +35,10 @@ config :textbin, expiration_cleanup_interval_ms: :timer.minutes(15), expiration_cleanup_batch_size: 500 +# Concurrent production indexes require a session-level lock rather than the +# transaction-level migration lock used by default. +config :textbin, Textbin.Repo, migration_lock: :pg_advisory_lock + config :textbin, Textbin.Storage, adapter: Textbin.Storage.Local, opts: {:replace, [root: "storage"]} diff --git a/lib/textbin/administration.ex b/lib/textbin/administration.ex index dc40273..993a4bc 100644 --- a/lib/textbin/administration.ex +++ b/lib/textbin/administration.ex @@ -12,10 +12,21 @@ defmodule Textbin.Administration do alias Textbin.Accounts alias Textbin.Accounts.{Scope, User, UserToken} alias Textbin.Administration.PlatformAuditEvent + + alias Textbin.Organizations.{ + Organization, + OrganizationMembership, + Workspace, + WorkspaceMembership + } + + alias Textbin.Pastes.Paste alias Textbin.Repo @platform_admin_role "admin" @authority_lock_key 8_174_021_483_001 + @default_page_size 25 + @max_page_size 100 @doc "Returns the current user when the scope has active platform authority." def authorize_platform_admin(%Scope{user: %User{id: user_id}}) do @@ -27,12 +38,166 @@ defmodule Textbin.Administration do def authorize_platform_admin(_scope), do: {:error, :forbidden} - @doc "Subscribes the caller to authority changes for its current user." - def subscribe_to_platform_authority(%Scope{user: %User{id: user_id}}) do - Phoenix.PubSub.subscribe(Textbin.PubSub, platform_authority_topic(user_id)) + @doc "Returns bounded installation totals for the administration overview." + def get_installation_overview(scope) do + with {:ok, _admin} <- authorize_platform_admin(scope) do + now = Paste.utc_now_ms() + + {:ok, + %{ + registered_users: Repo.aggregate(from(u in User, where: u.kind == "registered"), :count), + suspended_users: + Repo.aggregate(from(u in User, where: not is_nil(u.suspended_at)), :count), + organizations: Repo.aggregate(Organization, :count), + workspaces: Repo.aggregate(Workspace, :count), + active_pastes: + Repo.aggregate( + from(p in Paste, where: is_nil(p.expires_at) or p.expires_at > ^now), + :count + ), + active_paste_bytes: + Repo.one( + from p in Paste, + where: is_nil(p.expires_at) or p.expires_at > ^now, + select: + fragment( + "COALESCE(SUM(COALESCE(?, octet_length(?), 0)), 0)::bigint", + p.size_bytes, + p.data + ) + ) + }} + end end - def subscribe_to_platform_authority(_scope), do: {:error, :forbidden} + @doc "Looks up exact user, organization, and workspace identifiers without broad search." + def lookup(scope, term) when is_binary(term) do + with {:ok, _admin} <- authorize_platform_admin(scope) do + term = String.trim(term) + + {:ok, + %{ + user: lookup_user(term), + organization: lookup_organization(term), + workspace: lookup_workspace(term) + }} + end + end + + def lookup(scope, _term) do + with {:ok, _admin} <- authorize_platform_admin(scope) do + {:ok, %{user: nil, organization: nil, workspace: nil}} + end + end + + @doc "Lists active public paste metadata newest first without loading paste bodies." + def list_recent_public_pastes(scope, opts \\ []) do + with {:ok, _admin} <- authorize_platform_admin(scope) do + limit = page_limit(Keyword.get(opts, :limit)) + offset = page_offset(Keyword.get(opts, :page), limit) + now = Paste.utc_now_ms() + + query = + from paste in Paste, + join: workspace in Workspace, + on: workspace.id == paste.workspace_id, + join: organization in Organization, + on: organization.id == workspace.organization_id, + where: + paste.audience == "public" and workspace.external_sharing_policy == "public" and + is_nil(workspace.deletion_requested_at) and + is_nil(organization.deletion_requested_at) and + (is_nil(paste.expires_at) or paste.expires_at > ^now), + order_by: [desc: paste.inserted_at, desc: paste.id], + limit: ^(limit + 1), + offset: ^offset, + select: %{ + id: paste.id, + size_bytes: paste.size_bytes, + content_type: paste.content_type, + syntax_highlight: paste.syntax_highlight, + audience: paste.audience, + expires_at: paste.expires_at, + inserted_at: paste.inserted_at, + organization_name: organization.name, + workspace_name: workspace.name + } + + {:ok, page(Repo.all(query), limit, Keyword.get(opts, :page))} + end + end + + @doc "Lists largest active paste metadata without exposing non-public capability IDs." + def list_largest_pastes(scope, opts \\ []) do + with {:ok, _admin} <- authorize_platform_admin(scope) do + limit = page_limit(Keyword.get(opts, :limit)) + offset = page_offset(Keyword.get(opts, :page), limit) + now = Paste.utc_now_ms() + + query = + from paste in Paste, + join: workspace in Workspace, + on: workspace.id == paste.workspace_id, + join: organization in Organization, + on: organization.id == workspace.organization_id, + where: + is_nil(workspace.deletion_requested_at) and + is_nil(organization.deletion_requested_at) and + (is_nil(paste.expires_at) or paste.expires_at > ^now), + order_by: [ + desc_nulls_last: paste.size_bytes, + desc: paste.inserted_at, + desc: paste.id + ], + limit: ^(limit + 1), + offset: ^offset, + select: %{ + id: + type( + fragment( + "CASE WHEN ? = 'public' AND ? = 'public' THEN ? ELSE NULL END", + paste.audience, + workspace.external_sharing_policy, + paste.id + ), + :binary_id + ), + size_bytes: + fragment( + "COALESCE(?, octet_length(?), 0)::bigint", + paste.size_bytes, + paste.data + ), + content_type: paste.content_type, + syntax_highlight: paste.syntax_highlight, + audience: paste.audience, + expires_at: paste.expires_at, + inserted_at: paste.inserted_at, + organization_name: organization.name, + workspace_name: workspace.name + } + + {:ok, page(Repo.all(query), limit, Keyword.get(opts, :page))} + end + end + + @doc "Lists the append-only platform audit log newest first." + def list_platform_audit_events(scope, opts \\ []) do + with {:ok, _admin} <- authorize_platform_admin(scope) do + limit = page_limit(Keyword.get(opts, :limit)) + + with {:ok, query} <- platform_audit_event_query(Keyword.get(opts, :cursor)) do + events = Repo.all(from event in query, limit: ^(limit + 1)) + entries = Enum.take(events, limit) + + {:ok, + %{ + entries: entries, + next_cursor: if(length(events) > limit, do: List.last(entries).id) + }} + end + end + end @doc false def authorize_account_deletion(%Scope{user: %User{id: user_id}} = scope) do @@ -90,36 +255,46 @@ defmodule Textbin.Administration do @doc "Revokes platform authority while preserving one active administrator." def revoke_platform_admin(scope, target, reason, opts \\ []) do - with {:ok, reason} <- normalize_reason(reason), - {:ok, target_id} <- user_id(target) do - authority_transaction( - scope, - &revoke_platform_admin_in_transaction(&1, target_id, reason, opts) - ) - |> notify_platform_authority_change() - end + result = + with {:ok, reason} <- normalize_reason(reason), + {:ok, target_id} <- user_id(target) do + authority_transaction( + scope, + &revoke_platform_admin_in_transaction(&1, target_id, reason, opts) + ) + end + + notify_platform_authority_change(result) end @doc "Grants a replacement and revokes an administrator in one transaction." def transfer_platform_admin(scope, target, replacement, reason, opts \\ []) do - with {:ok, reason} <- normalize_reason(reason), - {:ok, target_id} <- user_id(target), - {:ok, replacement_id} <- user_id(replacement), - :ok <- distinct_users(target_id, replacement_id) do - authority_transaction( - scope, - &transfer_platform_admin_in_transaction( - &1, - target_id, - replacement_id, - reason, - opts + result = + with {:ok, reason} <- normalize_reason(reason), + {:ok, target_id} <- user_id(target), + {:ok, replacement_id} <- user_id(replacement), + :ok <- distinct_users(target_id, replacement_id) do + authority_transaction( + scope, + &transfer_platform_admin_in_transaction( + &1, + target_id, + replacement_id, + reason, + opts + ) ) - ) - |> notify_platform_authority_change() - end + end + + notify_platform_authority_change(result) end + @doc "Subscribes the caller to authority changes for its current user." + def subscribe_to_platform_authority(%Scope{user: %User{id: user_id}}), + do: Phoenix.PubSub.subscribe(Textbin.PubSub, platform_authority_topic(user_id)) + + def subscribe_to_platform_authority(_scope), do: {:error, :forbidden} + @doc "Suspends an account and revokes all of its authentication tokens." def suspend_user(scope, target, reason, opts \\ []) do result = @@ -499,13 +674,204 @@ defmodule Textbin.Administration do defp disconnect_suspended_sessions(result), do: result + defp lookup_user(term) do + id = cast_uuid(term) + + user = + Repo.one( + from user in User, + where: user.email == ^String.downcase(term) or user.id == ^id, + select: %{ + id: user.id, + email: user.email, + kind: user.kind, + platform_role: user.platform_role, + confirmed_at: user.confirmed_at, + suspended_at: user.suspended_at, + inserted_at: user.inserted_at + } + ) + + case user do + nil -> + nil + + user -> + Map.merge(user, %{ + organization_memberships: + Repo.aggregate( + from(m in OrganizationMembership, where: m.user_id == ^user.id), + :count + ), + workspace_memberships: + Repo.aggregate(from(m in WorkspaceMembership, where: m.user_id == ^user.id), :count), + pastes: + Repo.aggregate(from(p in Paste, where: p.created_by_user_id == ^user.id), :count) + }) + end + end + + defp lookup_organization(term) do + id = cast_uuid(term) + + organization = + Repo.one( + from organization in Organization, + where: organization.slug == ^term or organization.id == ^id, + select: %{ + id: organization.id, + name: organization.name, + slug: organization.slug, + kind: organization.kind, + deletion_requested_at: organization.deletion_requested_at, + inserted_at: organization.inserted_at + } + ) + + case organization do + nil -> + nil + + organization -> + workspace_ids = + from(workspace in Workspace, + where: workspace.organization_id == ^organization.id, + select: workspace.id + ) + + Map.merge(organization, %{ + members: + Repo.aggregate( + from(m in OrganizationMembership, where: m.organization_id == ^organization.id), + :count + ), + workspaces: Repo.aggregate(workspace_ids, :count), + pastes: + Repo.aggregate( + from(p in Paste, where: p.workspace_id in subquery(workspace_ids)), + :count + ) + }) + end + end + + defp lookup_workspace(term) do + id = cast_uuid(term) + + base_query = + from workspace in Workspace, + join: organization in Organization, + on: organization.id == workspace.organization_id + + query = + case String.split(term, "/", parts: 2) do + [organization_slug, workspace_slug] -> + from [workspace, organization] in base_query, + where: organization.slug == ^organization_slug and workspace.slug == ^workspace_slug + + _other -> + from [workspace, _organization] in base_query, where: workspace.id == ^id + end + + workspace = + Repo.one( + from [workspace, organization] in query, + select: %{ + id: workspace.id, + name: workspace.name, + slug: workspace.slug, + visibility: workspace.visibility, + external_sharing_policy: workspace.external_sharing_policy, + is_default: workspace.is_default, + deletion_requested_at: workspace.deletion_requested_at, + inserted_at: workspace.inserted_at, + organization_name: organization.name, + organization_slug: organization.slug + } + ) + + case workspace do + nil -> + nil + + workspace -> + Map.merge(workspace, %{ + members: + Repo.aggregate( + from(m in WorkspaceMembership, where: m.workspace_id == ^workspace.id), + :count + ), + pastes: Repo.aggregate(from(p in Paste, where: p.workspace_id == ^workspace.id), :count) + }) + end + end + + defp cast_uuid(value) do + case Ecto.UUID.cast(value) do + {:ok, id} -> id + :error -> Ecto.UUID.generate() + end + end + + defp page(items, limit, requested_page) do + current_page = page_number(requested_page) + + %{ + entries: Enum.take(items, limit), + page: current_page, + previous_page: if(current_page > 1, do: current_page - 1), + next_page: if(length(items) > limit, do: current_page + 1) + } + end + + defp page_offset(requested_page, limit), do: (page_number(requested_page) - 1) * limit + + defp page_number(page) when is_binary(page) do + case Integer.parse(page) do + {page, ""} -> max(page, 1) + _error -> 1 + end + end + + defp page_number(page) when is_integer(page), do: max(page, 1) + defp page_number(_page), do: 1 + + defp page_limit(limit) when is_binary(limit) do + case Integer.parse(limit) do + {limit, ""} -> page_limit(limit) + _error -> @default_page_size + end + end + + defp page_limit(limit) when is_integer(limit), do: limit |> max(1) |> min(@max_page_size) + defp page_limit(_limit), do: @default_page_size + + defp platform_audit_event_query(nil) do + {:ok, from(event in PlatformAuditEvent, order_by: [desc: event.inserted_at, desc: event.id])} + end + + defp platform_audit_event_query(cursor) do + with {:ok, cursor_id} <- Ecto.UUID.cast(cursor), + %PlatformAuditEvent{} = cursor_event <- Repo.get(PlatformAuditEvent, cursor_id) do + {:ok, + from(event in PlatformAuditEvent, + where: + event.inserted_at < ^cursor_event.inserted_at or + (event.inserted_at == ^cursor_event.inserted_at and event.id < ^cursor_event.id), + order_by: [desc: event.inserted_at, desc: event.id] + )} + else + _error -> {:error, :not_found} + end + end + defp notify_platform_authority_change({:ok, %User{id: user_id}} = result) do broadcast_platform_authority_change(user_id) result end - defp notify_platform_authority_change({:ok, %{revoked: %User{id: user_id}}} = result) do - broadcast_platform_authority_change(user_id) + defp notify_platform_authority_change({:ok, %{revoked: %User{id: revoked_id}}} = result) do + broadcast_platform_authority_change(revoked_id) result end diff --git a/lib/textbin_web/components/layouts.ex b/lib/textbin_web/components/layouts.ex index 7830e61..24eee27 100644 --- a/lib/textbin_web/components/layouts.ex +++ b/lib/textbin_web/components/layouts.ex @@ -579,6 +579,13 @@ defmodule TextbinWeb.Layouts do Register <% else %> + <.link + :if={platform_admin?(@current_scope)} + navigate={~p"/admin"} + class="btn btn-ghost btn-sm w-full justify-start" + > + <.icon name="hero-shield-check" class="size-4" /> Administration + <.link href={~p"/users/settings"} class="btn btn-ghost btn-sm w-full justify-start" @@ -621,6 +628,9 @@ defmodule TextbinWeb.Layouts do defp guest_scope?(_scope), do: false + defp platform_admin?(%{user: %{platform_role: "admin", suspended_at: nil}}), do: true + defp platform_admin?(_scope), do: false + defp application_shell?(%{ user: %Textbin.Accounts.User{} = user, organization: %Textbin.Organizations.Organization{}, diff --git a/lib/textbin_web/live/ui/admin_live.ex b/lib/textbin_web/live/ui/admin_live.ex index 060bdd8..2550356 100644 --- a/lib/textbin_web/live/ui/admin_live.ex +++ b/lib/textbin_web/live/ui/admin_live.ex @@ -3,53 +3,282 @@ defmodule TextbinWeb.UI.AdminLive do on_mount {TextbinWeb.UserAuth, :require_platform_admin} + alias Textbin.Administration + + @page_size 10 + + embed_templates "admin_live/*" + + @impl true + def render(assigns), do: index(assigns) + @impl true def mount(_params, _session, socket) do - {:ok, assign(socket, :page_title, "Administration")} + {:ok, + socket + |> assign(:page_title, "Platform administration") + |> assign(:lookup_form, to_form(%{"query" => ""}, as: :lookup)) + |> assign(:lookup_performed?, false) + |> assign(:lookup, empty_lookup()) + |> stream_configure(:largest_pastes, dom_id: &"largest-paste-row-#{&1.row_key}")} end @impl true - def render(assigns) do + def handle_params(params, _uri, socket) do + scope = socket.assigns.current_scope + + with {:ok, overview} <- Administration.get_installation_overview(scope), + {:ok, recent_page} <- + Administration.list_recent_public_pastes(scope, + limit: @page_size, + page: params["recent_page"] + ), + {:ok, largest_page} <- + Administration.list_largest_pastes(scope, + limit: @page_size, + page: params["largest_page"] + ), + {:ok, audit_page} <- + Administration.list_platform_audit_events(scope, + limit: @page_size + ) do + {:noreply, + socket + |> assign(:overview, overview) + |> assign(:recent_page, recent_page) + |> assign(:largest_page, largest_page) + |> assign(:audit_next_cursor, audit_page.next_cursor) + |> stream(:recent_pastes, recent_page.entries, reset: true) + |> stream(:largest_pastes, largest_stream_entries(largest_page), reset: true) + |> stream(:platform_audit_events, audit_page.entries, reset: true)} + else + {:error, :forbidden} -> {:noreply, leave_admin(socket)} + end + end + + @impl true + def handle_event("lookup", %{"lookup" => %{"query" => query}}, socket) do + case Administration.lookup(socket.assigns.current_scope, query) do + {:ok, lookup} -> + {:noreply, + socket + |> assign(:lookup_form, to_form(%{"query" => String.trim(query)}, as: :lookup)) + |> assign(:lookup_performed?, true) + |> assign(:lookup, lookup)} + + {:error, :forbidden} -> + {:noreply, leave_admin(socket)} + end + end + + def handle_event("load_more_audit", _params, %{assigns: %{audit_next_cursor: nil}} = socket), + do: {:noreply, socket} + + def handle_event("load_more_audit", _params, socket) do + case Administration.list_platform_audit_events(socket.assigns.current_scope, + limit: @page_size, + cursor: socket.assigns.audit_next_cursor + ) do + {:ok, page} -> + {:noreply, + socket + |> assign(:audit_next_cursor, page.next_cursor) + |> stream(:platform_audit_events, page.entries)} + + {:error, :forbidden} -> + {:noreply, leave_admin(socket)} + + {:error, :not_found} -> + {:noreply, assign(socket, :audit_next_cursor, nil)} + end + end + + defp leave_admin(socket) do + socket + |> put_flash(:error, "Your platform administration access has changed.") + |> push_navigate(to: ~p"/") + end + + defp empty_lookup, do: %{user: nil, organization: nil, workspace: nil} + + defp largest_stream_entries(page) do + page.entries + |> Enum.with_index() + |> Enum.map(fn {paste, index} -> Map.put(paste, :row_key, "#{page.page}-#{index}") end) + end + + def format_bytes(nil), do: "0 B" + + def format_bytes(bytes) when bytes < 1_024, do: "#{bytes} B" + + def format_bytes(bytes) when bytes < 1_048_576, + do: "#{Float.round(bytes / 1_024, 1)} KiB" + + def format_bytes(bytes), do: "#{Float.round(bytes / 1_048_576, 1)} MiB" + + def format_timestamp(nil), do: "Never" + + def format_timestamp(timestamp), + do: Calendar.strftime(timestamp, "%Y-%m-%d %H:%M UTC") + + def account_status(%{suspended_at: %DateTime{}}), do: "Suspended" + def account_status(%{kind: "guest"}), do: "Guest" + def account_status(%{confirmed_at: nil}), do: "Unconfirmed" + def account_status(_user), do: "Active" + + def status_class("Suspended"), do: "bg-error/10 text-error" + def status_class("Active"), do: "bg-success/10 text-success" + def status_class(_status), do: "bg-warning/10 text-warning" + + 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(action), do: action + + def page_params(kind, page, assigns) do + %{ + recent_page: if(kind == :recent, do: page, else: assigns.recent_page.page), + largest_page: if(kind == :largest, do: page, else: assigns.largest_page.page) + } + end + + attr :label, :string, required: true + attr :value, :any, required: true + attr :icon, :string, required: true + + def metric_card(assigns) do ~H""" - -
-
-
-
-
- <.icon name="hero-shield-check" class="size-6" /> -
-
-

- Platform controls -

-

- Administration -

-

- This restricted area is authorized against current platform authority on every - mount and authority change. -

-
-
-
+
+
+ <.icon name={@icon} class="size-4.5" /> +
+

{@value}

+

{@label}

+
+ """ + end + + attr :label, :string, required: true + attr :value, :any, required: true -
-
- - <.icon name="hero-lock-closed" class="size-4" /> + def summary_stat(assigns) do + ~H""" +
+
{@label}
+
{@value}
+
+ """ + end + + attr :id, :string, required: true + attr :title, :string, required: true + attr :description, :string, required: true + attr :icon, :string, required: true + attr :stream, :any, required: true + attr :page, :map, required: true + attr :kind, :atom, required: true + attr :assigns, :map, required: true + + def paste_panel(assigns) do + ~H""" +
+
+
+ <.icon name={@icon} class="size-5" /> +
+
+

{@title}

+

{@description}

+
+
+
+ +
+
+
+ + {paste.audience} -
-

Authorization boundary active

-

- Operational views will be introduced in the next delivery phase. -

-
+ {paste.content_type} +
+

+ {paste.organization_name} / {paste.workspace_name} +

+
+ + Expires {format_timestamp(paste.expires_at)}
-
-
-
+
+

+ {format_bytes(paste.size_bytes)} +

+ <.link + :if={paste.id} + navigate={~p"/pastes/#{paste.id}"} + class="mt-2 inline-flex items-center gap-1 text-xs font-semibold text-primary hover:underline" + > + Open <.icon name="hero-arrow-up-right" class="size-3" /> + + ID protected +
+ + + <.pagination + id={"#{@id}-pagination"} + page={@page} + kind={@kind} + assigns={@assigns} + /> + + """ + end + + attr :id, :string, required: true + attr :page, :map, required: true + attr :kind, :atom, required: true + attr :assigns, :map, required: true + + def pagination(assigns) do + ~H""" + """ end end diff --git a/lib/textbin_web/live/ui/admin_live/index.html.heex b/lib/textbin_web/live/ui/admin_live/index.html.heex new file mode 100644 index 0000000..70bda3a --- /dev/null +++ b/lib/textbin_web/live/ui/admin_live/index.html.heex @@ -0,0 +1,255 @@ + +
+
+ +
+
+ <.icon name="hero-shield-check" class="size-4" /> Trusted operators +
+

+ Platform administration +

+

+ Installation-wide operational metadata. Paste bodies and bearer credentials are never loaded here. +

+
+
+ +
+
+
+

Health

+

+ Installation overview +

+
+ + + + Live database totals + +
+
+ <.metric_card + label="Registered users" + value={@overview.registered_users} + icon="hero-users" + /> + <.metric_card label="Suspended" value={@overview.suspended_users} icon="hero-no-symbol" /> + <.metric_card + label="Organizations" + value={@overview.organizations} + icon="hero-building-office-2" + /> + <.metric_card label="Workspaces" value={@overview.workspaces} icon="hero-squares-2x2" /> + <.metric_card + label="Active pastes" + value={@overview.active_pastes} + icon="hero-document-text" + /> + <.metric_card + label="Active storage" + value={format_bytes(@overview.active_paste_bytes)} + icon="hero-circle-stack" + /> +
+
+ +
+
+
+

Exact lookup

+

Find installation records

+

+ Enter a user email or UUID, an organization slug, a workspace UUID, or organization/workspace. +

+ <.form + for={@lookup_form} + id="admin-lookup-form" + phx-submit="lookup" + class="mt-5 flex gap-2" + > + <.input + field={@lookup_form[:query]} + type="search" + placeholder="name@example.com or exact identifier" + autocomplete="off" + class="w-full rounded-xl border border-base-300 bg-base-100 px-4 py-2.5 text-sm text-base-content outline-none transition placeholder:text-base-content/35 focus:border-primary focus:ring-4 focus:ring-primary/10" + /> + + +
+ +
+
+ Exact matches and bounded membership summaries appear here. +
+
+ No exact records matched that identifier. +
+ +
+
+
+

+ User +

+

+ {@lookup.user.email} +

+

+ {@lookup.user.id} +

+
+ <% status = account_status(@lookup.user) %> + + {status} + +
+
+ <.summary_stat label="Platform role" value={@lookup.user.platform_role || "None"} /> + <.summary_stat label="Organizations" value={@lookup.user.organization_memberships} /> + <.summary_stat label="Workspaces" value={@lookup.user.workspace_memberships} /> + <.summary_stat label="Pastes" value={@lookup.user.pastes} /> +
+
+ +
+

+ Organization +

+

{@lookup.organization.name}

+

+ /{@lookup.organization.slug} +

+
+ <.summary_stat label="Members" value={@lookup.organization.members} /> + <.summary_stat label="Workspaces" value={@lookup.organization.workspaces} /> + <.summary_stat label="Pastes" value={@lookup.organization.pastes} /> +
+
+ +
+

+ Workspace +

+

{@lookup.workspace.name}

+

+ /{@lookup.workspace.organization_slug}/{@lookup.workspace.slug} +

+
+ <.summary_stat label="Members" value={@lookup.workspace.members} /> + <.summary_stat label="Pastes" value={@lookup.workspace.pastes} /> + <.summary_stat label="Sharing" value={@lookup.workspace.external_sharing_policy} /> +
+
+
+
+
+ +
+ <.paste_panel + id="recent-public-pastes" + title="Recent public pastes" + description="Only active public pastes from workspaces that allow public sharing." + icon="hero-globe-alt" + stream={@streams.recent_pastes} + page={@recent_page} + kind={:recent} + assigns={assigns} + /> + <.paste_panel + id="largest-pastes" + title="Largest pastes" + description="Body-free metadata across audiences; non-public capability IDs stay hidden." + icon="hero-arrows-pointing-out" + stream={@streams.largest_pastes} + page={@largest_page} + kind={:largest} + assigns={assigns} + /> +
+ +
+
+
+ <.icon name="hero-shield-check" class="size-5" /> +
+
+

Platform audit log

+

+ Append-only installation authority and account events. +

+
+
+
+ +
+
+

{audit_title(event.action)}

+

+ {event.actor_label} ยท {event.reason} +

+

+ {event.target_type}:{event.target_id} +

+
+ +
+
+
+ +
+
+
+
diff --git a/priv/repo/migrations/20260826090000_add_administration_paste_indexes.exs b/priv/repo/migrations/20260826090000_add_administration_paste_indexes.exs new file mode 100644 index 0000000..49d2a7e --- /dev/null +++ b/priv/repo/migrations/20260826090000_add_administration_paste_indexes.exs @@ -0,0 +1,24 @@ +defmodule Textbin.Repo.Migrations.AddAdministrationPasteIndexes do + use Ecto.Migration + + @disable_ddl_transaction true + + def change do + execute( + "UPDATE pastes SET size_bytes = octet_length(data) WHERE size_bytes IS NULL AND data IS NOT NULL", + "SELECT 1" + ) + + create index(:pastes, [asc: :visibility, desc: :inserted_at, desc: :id], + name: :pastes_admin_recent_visibility_index, + concurrently: true + ) + + create index( + :pastes, + [desc_nulls_last: :size_bytes, desc: :inserted_at, desc: :id], + name: :pastes_admin_largest_index, + concurrently: true + ) + end +end diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index b9b1e4c..6cfe6c6 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -9,3 +9,60 @@ # # We recommend using the bang functions (`insert!`, `update!` # and so on) as they will fail if something goes wrong. + +if Mix.env() == :prod do + raise "refusing to seed known development credentials in production" +end + +alias Textbin.Accounts +alias Textbin.Accounts.User +alias Textbin.Administration +alias Textbin.Repo + +password = "supersecure!" + +emails = [ + "test@example.com", + "alex@example.com", + "blair@example.com", + "casey@example.com", + "devon@example.com", + "ellis@example.com", + "frankie@example.com", + "gray@example.com", + "harper@example.com", + "jules@example.com" +] + +seed_user = fn email -> + user = + case Accounts.get_user_by_email(email) do + nil -> + {:ok, user} = Accounts.register_user(%{email: email}) + user + + %User{} = user -> + user + end + + user = + if user.confirmed_at do + user + else + user + |> User.confirm_changeset() + |> Repo.update!() + end + + {:ok, {user, _expired_tokens}} = + Accounts.update_user_password(user, %{password: password}) + + user +end + +Enum.each(emails, seed_user) + +{:ok, admin_result} = Administration.bootstrap_platform_admin("test@example.com") + +IO.puts("Seeded #{length(emails)} development users with password #{inspect(password)}.") +IO.puts("test@example.com platform admin: #{admin_result}") diff --git a/priv/repo/structure.sql b/priv/repo/structure.sql index 26fb9f9..59ba895 100644 --- a/priv/repo/structure.sql +++ b/priv/repo/structure.sql @@ -2,7 +2,7 @@ -- PostgreSQL database dump -- -\restrict 6gtWhRfmKpVJkaDO90gYdQMd8oshwcJAhZ5etVA7wO4LV6cIsOJfb8kQjX2kxyN +\restrict 3lsjmiMYDgwKtdaQ3bfz4i4fn6sTwWsM6QKCAIFamw5UMkGMy2klQNCCp5rysQ4 -- Dumped from database version 17.10 -- Dumped by pg_dump version 17.10 @@ -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 text NOT NULL, + reason character varying(255) NOT NULL, request_id character varying(255), metadata jsonb DEFAULT '{}'::jsonb NOT NULL, inserted_at timestamp without time zone NOT NULL, @@ -382,6 +382,20 @@ CREATE UNIQUE INDEX organizations_personal_owner_id_index ON public.organization CREATE UNIQUE INDEX organizations_slug_index ON public.organizations USING btree (slug); +-- +-- Name: pastes_admin_largest_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX pastes_admin_largest_index ON public.pastes USING btree (size_bytes DESC NULLS LAST, inserted_at DESC, id DESC); + + +-- +-- Name: pastes_admin_recent_visibility_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX pastes_admin_recent_visibility_index ON public.pastes USING btree (visibility, inserted_at DESC, id DESC); + + -- -- Name: pastes_created_by_user_id_index; Type: INDEX; Schema: public; Owner: - -- @@ -628,7 +642,7 @@ ALTER TABLE ONLY public.workspaces -- PostgreSQL database dump complete -- -\unrestrict 6gtWhRfmKpVJkaDO90gYdQMd8oshwcJAhZ5etVA7wO4LV6cIsOJfb8kQjX2kxyN +\unrestrict 3lsjmiMYDgwKtdaQ3bfz4i4fn6sTwWsM6QKCAIFamw5UMkGMy2klQNCCp5rysQ4 INSERT INTO public."schema_migrations" (version) VALUES (20260706061942); INSERT INTO public."schema_migrations" (version) VALUES (20260709081001); @@ -651,3 +665,4 @@ INSERT INTO public."schema_migrations" (version) VALUES (20260814080000); INSERT INTO public."schema_migrations" (version) VALUES (20260814081000); INSERT INTO public."schema_migrations" (version) VALUES (20260814082000); INSERT INTO public."schema_migrations" (version) VALUES (20260822090000); +INSERT INTO public."schema_migrations" (version) VALUES (20260826090000); diff --git a/rfd/0001/IMPLEMENTATION.org b/rfd/0001/IMPLEMENTATION.org index df79525..7c9d58c 100644 --- a/rfd/0001/IMPLEMENTATION.org +++ b/rfd/0001/IMPLEMENTATION.org @@ -39,8 +39,8 @@ for every transport. Ship useful installation, account, organization, workspace, paste-metadata, and audit views without creating a privileged content-reading path. -- [ ] Admin list queries are paginated, scoped in SQL, and do not load paste bodies. -- [ ] General recent-paste discovery exposes only public pastes, and metadata views +- [X] Admin list queries are paginated, scoped in SQL, and do not load paste bodies. +- [X] General recent-paste discovery exposes only public pastes, and metadata views do not create a privileged path to workspace-only or arbitrary unlisted content. * Phase 4: Moderation and report review diff --git a/test/textbin/administration/migration_test.exs b/test/textbin/administration/migration_test.exs new file mode 100644 index 0000000..11879d2 --- /dev/null +++ b/test/textbin/administration/migration_test.exs @@ -0,0 +1,104 @@ +defmodule Textbin.Administration.MigrationTest do + use ExUnit.Case, async: false + + alias Textbin.MigrationRepo + + @foundation_version 20_260_822_090_000 + @administration_indexes_version 20_260_826_090_000 + + test "backfills inline sizes and creates administration indexes online" do + database = "textbin_admin_migration_#{Ecto.UUID.generate()}" + {:ok, admin} = Postgrex.start_link(admin_config()) + Process.unlink(admin) + Postgrex.query!(admin, ~s(CREATE DATABASE "#{database}"), []) + + on_exit(fn -> + Postgrex.query!(admin, "DROP DATABASE IF EXISTS \"#{database}\" WITH (FORCE)", []) + if Process.alive?(admin), do: GenServer.stop(admin) + end) + + previous_config = Application.get_env(:textbin, MigrationRepo) + Application.put_env(:textbin, MigrationRepo, repo_config(database)) + + on_exit(fn -> restore_repo_config(previous_config) end) + + {:ok, repo} = MigrationRepo.start_link() + Process.unlink(repo) + on_exit(fn -> if Process.alive?(repo), do: GenServer.stop(repo) end) + + migrations = Path.expand("../../../priv/repo/migrations", __DIR__) + Ecto.Migrator.run(MigrationRepo, migrations, :up, to: @foundation_version) + + 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 index_definition("pastes_admin_recent_visibility_index") =~ + "(visibility, inserted_at DESC, id DESC)" + + assert index_definition("pastes_admin_largest_index") =~ + "(size_bytes DESC NULLS LAST, inserted_at DESC, id DESC)" + + assert MigrationRepo.config()[:migration_lock] == :pg_advisory_lock + migration = Textbin.Repo.Migrations.AddAdministrationPasteIndexes + assert apply(migration, :__migration__, [])[:disable_ddl_transaction] + end + + defp insert_legacy_inline_paste do + user_id = Ecto.UUID.generate() + organization_id = Ecto.UUID.generate() + workspace_id = Ecto.UUID.generate() + paste_id = Ecto.UUID.generate() + + MigrationRepo.query!( + "INSERT INTO users (id, email, confirmed_at, inserted_at, updated_at) VALUES ($1::uuid, $2, NOW(), NOW(), NOW())", + [uuid(user_id), "migration-#{user_id}@example.com"] + ) + + MigrationRepo.query!( + "INSERT INTO organizations (id, name, slug, kind, personal_owner_id, inserted_at, updated_at) VALUES ($1::uuid, 'Personal', $2, 'personal', $3::uuid, NOW(), NOW())", + [uuid(organization_id), "personal-#{organization_id}", uuid(user_id)] + ) + + MigrationRepo.query!( + "INSERT INTO workspaces (id, organization_id, created_by_id, name, slug, visibility, external_sharing_policy, is_default, inserted_at, updated_at) VALUES ($1::uuid, $2::uuid, $3::uuid, 'Personal', 'default', 'open', 'public', TRUE, NOW(), NOW())", + [uuid(workspace_id), uuid(organization_id), uuid(user_id)] + ) + + MigrationRepo.query!( + "INSERT INTO pastes (id, data, size_bytes, visibility, workspace_id, created_by_user_id, inserted_at, updated_at) VALUES ($1::uuid, 'legacy inline content', NULL, 'public', $2::uuid, $3::uuid, NOW(), NOW())", + [uuid(paste_id), uuid(workspace_id), uuid(user_id)] + ) + + paste_id + end + + defp index_definition(name) do + %{rows: [[definition]]} = + MigrationRepo.query!("SELECT indexdef FROM pg_indexes WHERE indexname = $1", [name]) + + definition + end + + defp admin_config do + Textbin.Repo.config() + |> Keyword.take([:hostname, :port, :username, :password]) + |> Keyword.put(:database, "postgres") + end + + defp repo_config(database) do + Textbin.Repo.config() + |> Keyword.drop([:name, :pool, :pool_size, :database]) + |> Keyword.put(:database, database) + |> Keyword.put(:pool_size, 2) + end + + defp restore_repo_config(nil), do: Application.delete_env(:textbin, MigrationRepo) + defp restore_repo_config(config), do: Application.put_env(:textbin, MigrationRepo, config) + + defp uuid(id), do: Ecto.UUID.dump!(id) +end diff --git a/test/textbin/administration_test.exs b/test/textbin/administration_test.exs index b12322d..8442680 100644 --- a/test/textbin/administration_test.exs +++ b/test/textbin/administration_test.exs @@ -6,6 +6,8 @@ defmodule Textbin.AdministrationTest do alias Textbin.Administration alias Textbin.Administration.PlatformAuditEvent alias Textbin.Organizations + alias Textbin.Pastes + alias Textbin.Pastes.Paste alias Textbin.Release import Textbin.AccountsFixtures @@ -331,6 +333,175 @@ defmodule Textbin.AdministrationTest do end end + describe "administration reads" do + setup do + admin = admin_fixture() + %{admin: admin, scope: admin_scope(admin)} + end + + test "reloads authority for every read and rejects ordinary users", %{ + admin: admin, + scope: scope + } do + ordinary_scope = user_scope_fixture() + + for operation <- [ + &Administration.get_installation_overview/1, + &Administration.lookup(&1, admin.email), + &Administration.list_recent_public_pastes/1, + &Administration.list_largest_pastes/1, + &Administration.list_platform_audit_events/1 + ] do + assert {:error, :forbidden} = operation.(ordinary_scope) + end + + assert {:ok, _overview} = Administration.get_installation_overview(scope) + Repo.update_all(from(user in User, where: user.id == ^admin.id), set: [platform_role: nil]) + assert {:error, :forbidden} = Administration.get_installation_overview(scope) + end + + test "returns installation totals and exact lookup summaries", %{scope: scope} do + target = user_fixture() + target_scope = user_scope_fixture(target) + organization = Organizations.get_personal_organization!(target) + workspace = personal_workspace_fixture(target) + assert {:ok, _paste} = Pastes.create_paste(target_scope, %{data: "lookup paste"}) + + assert {:ok, overview} = Administration.get_installation_overview(scope) + assert overview.registered_users >= 2 + assert overview.organizations >= 2 + assert overview.workspaces >= 2 + assert overview.active_pastes >= 1 + + assert {:ok, %{user: user}} = Administration.lookup(scope, String.upcase(target.email)) + assert user.id == target.id + assert user.organization_memberships == 1 + assert user.workspace_memberships == 1 + assert user.pastes == 1 + refute Map.has_key?(user, :hashed_password) + + assert {:ok, %{organization: found_organization}} = + Administration.lookup(scope, organization.slug) + + assert found_organization.id == organization.id + assert found_organization.members == 1 + assert found_organization.workspaces == 1 + + assert {:ok, %{workspace: found_workspace}} = + Administration.lookup(scope, "#{organization.slug}/#{workspace.slug}") + + assert found_workspace.id == workspace.id + assert found_workspace.members == 1 + assert found_workspace.pastes == 1 + end + + test "paginates body-free paste metadata and limits discovery to public pastes", %{ + scope: scope + } do + owner = user_fixture() + owner_scope = user_scope_fixture(owner) + + assert {:ok, public} = + Pastes.create_paste(owner_scope, %{ + data: "public-body-must-not-load", + audience: "public" + }) + + assert {:ok, unlisted} = + Pastes.create_paste(owner_scope, %{ + data: String.duplicate("u", 200), + audience: "unlisted" + }) + + assert {:ok, workspace_only} = + Pastes.create_paste(owner_scope, %{ + data: String.duplicate("w", 300), + audience: "workspace" + }) + + assert {:ok, recent_page} = + Administration.list_recent_public_pastes(scope, limit: 1) + + assert [%{id: public_id} = recent] = recent_page.entries + assert public_id == public.id + refute Map.has_key?(recent, :data) + refute Map.has_key?(recent, :storage_key) + + assert {:ok, largest_page} = Administration.list_largest_pastes(scope, limit: 2) + assert largest_page.next_page == 2 + assert [%{id: nil}, %{id: nil}] = largest_page.entries + + assert {:ok, second_page} = + Administration.list_largest_pastes(scope, limit: 2, page: largest_page.next_page) + + assert Enum.any?(second_page.entries, &(&1.id == public.id)) + refute Enum.any?(recent_page.entries, &(&1.id in [unlisted.id, workspace_only.id])) + end + + test "paginates platform audit events", %{scope: scope} do + _second_admin = admin_fixture() + + assert {:ok, first_page} = + Administration.list_platform_audit_events(scope, limit: 1) + + assert length(first_page.entries) == 1 + assert first_page.next_cursor + + assert {:ok, second_page} = + Administration.list_platform_audit_events(scope, + limit: 1, + cursor: first_page.next_cursor + ) + + assert length(second_page.entries) == 1 + assert first_page.entries != second_page.entries + end + + test "handles legacy inline pastes without size metadata", %{scope: scope} do + owner = user_fixture() + owner_scope = user_scope_fixture(owner) + workspace = personal_workspace_fixture(owner) + + assert {:ok, modern} = + Pastes.create_paste(owner_scope, %{data: "modern", audience: "public"}) + + legacy_data = "legacy inline content" + + legacy = + Repo.insert!(%Paste{ + data: legacy_data, + size_bytes: nil, + audience: "public", + workspace_id: workspace.id, + created_by_user_id: owner.id + }) + + assert {:ok, overview} = Administration.get_installation_overview(scope) + assert overview.active_paste_bytes >= modern.size_bytes + byte_size(legacy_data) + + assert {:ok, page} = Administration.list_largest_pastes(scope, limit: 100) + modern_index = Enum.find_index(page.entries, &(&1.id == modern.id)) + legacy_index = Enum.find_index(page.entries, &(&1.id == legacy.id)) + + assert modern_index < legacy_index + assert Enum.at(page.entries, legacy_index).size_bytes == byte_size(legacy_data) + end + + test "notifies a mounted administrator after revocation", %{admin: admin, scope: scope} do + actor = admin_fixture() + :ok = Administration.subscribe_to_platform_authority(scope) + + assert {:ok, %User{platform_role: nil}} = + Administration.revoke_platform_admin( + admin_scope(actor), + admin, + "rotation complete" + ) + + assert_receive :platform_authority_changed + end + end + test "platform audit events reject updates and deletes" do user = admin_fixture() event = Repo.one!(from event in PlatformAuditEvent, where: event.target_id == ^user.id) diff --git a/test/textbin_web/live/ui/admin_live_test.exs b/test/textbin_web/live/ui/admin_live_test.exs index fe684cf..d59a694 100644 --- a/test/textbin_web/live/ui/admin_live_test.exs +++ b/test/textbin_web/live/ui/admin_live_test.exs @@ -6,54 +6,133 @@ defmodule TextbinWeb.UI.AdminLiveTest do alias Textbin.Accounts.Scope alias Textbin.Administration + alias Textbin.Pastes alias Textbin.Repo alias TextbinWeb.ForbiddenError + setup %{conn: conn} do + admin = user_fixture() + assert {:ok, :granted} = Administration.bootstrap_platform_admin(admin.email) + admin = Repo.get!(Textbin.Accounts.User, admin.id) + + %{admin: admin, conn: log_in_user(conn, admin)} + end + test "requires authentication and current platform authority", %{conn: conn} do - assert {:error, {:redirect, %{to: path}}} = live(conn, ~p"/admin") - assert path == ~p"/users/log-in" + assert {:error, {:redirect, %{to: "/users/log-in"}}} = live(build_conn(), ~p"/admin") - user = user_fixture() + ordinary_user = user_fixture() assert_raise ForbiddenError, fn -> - live(log_in_user(conn, user), ~p"/admin") + ordinary_user + |> then(&log_in_user(build_conn(), &1)) + |> live(~p"/admin") end - assert {:ok, :granted} = Administration.bootstrap_platform_admin(user.email) + assert {:ok, view, _html} = live(conn, ~p"/admin") + assert has_element?(view, "#platform-admin-page") + end + + test "renders bounded operational metadata without paste bodies", %{conn: conn} do + owner = user_fixture() - assert {:ok, view, _html} = live(log_in_user(conn, user), ~p"/admin") - assert has_element?(view, "#admin-page") - assert has_element?(view, "#admin-foundation-status") + assert {:ok, paste} = + Pastes.create_paste(Scope.for_user(owner), %{ + data: "highly-sensitive-body-marker", + audience: "public" + }) + + assert {:ok, view, html} = live(conn, ~p"/admin") + + assert has_element?(view, "#installation-overview") + assert has_element?(view, "#admin-lookup-form") + assert has_element?(view, "#recent-public-pastes-entries a[href='/pastes/#{paste.id}']") + assert has_element?(view, "#largest-pastes") + assert has_element?(view, "#platform-audit-log") + refute html =~ "highly-sensitive-body-marker" end - test "leaves the panel promptly when current authority is revoked", %{conn: conn} do - actor = platform_admin_fixture() - target = platform_admin_fixture() - target_conn = log_in_user(conn, target) + test "looks up exact users and membership summaries", %{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-user-result", target.email) + refute has_element?(view, "#admin-lookup-empty") + + view + |> form("#admin-lookup-form", lookup: %{query: "missing@example.com"}) + |> render_submit() - assert {:ok, view, _html} = live(target_conn, ~p"/admin") + assert has_element?(view, "#admin-lookup-empty") + end + + test "renders every protected largest-paste row without exposing capability IDs", %{conn: conn} do + owner = user_fixture() + scope = Scope.for_user(owner) + + assert {:ok, first} = + Pastes.create_paste(scope, %{ + data: String.duplicate("a", 200), + audience: "unlisted" + }) + + assert {:ok, second} = + Pastes.create_paste(scope, %{ + data: String.duplicate("b", 100), + audience: "workspace" + }) + + assert {:ok, view, _html} = live(conn, ~p"/admin") + + assert has_element?(view, "#largest-pastes-entries article + article") + refute has_element?(view, "#largest-pastes-entries a[href='/pastes/#{first.id}']") + refute has_element?(view, "#largest-pastes-entries a[href='/pastes/#{second.id}']") + + row_ids = + view + |> render() + |> LazyHTML.from_fragment() + |> LazyHTML.query("#largest-pastes-entries article") + |> LazyHTML.attribute("id") + + assert length(row_ids) == 2 + assert length(Enum.uniq(row_ids)) == 2 + end + + test "leaves the panel promptly when authority is revoked", %{admin: admin, conn: conn} do + actor = user_fixture() + assert {:ok, :granted} = Administration.bootstrap_platform_admin(actor.email) + actor = Repo.get!(Textbin.Accounts.User, actor.id) + actor_scope = Scope.for_user(%{actor | authenticated_at: DateTime.utc_now(:second)}) + + assert {:ok, view, _html} = live(conn, ~p"/admin") monitor = monitor_proxy(view) - assert {:ok, _target} = - Administration.revoke_platform_admin( - admin_scope(actor), - target, - "rotation complete" - ) + assert {:ok, _revoked} = + Administration.revoke_platform_admin(actor_scope, admin, "operator rotation") assert_redirect(view, ~p"/") assert_full_redirect(monitor, ~p"/") end - test "leaves the panel promptly when the administrator is suspended", %{conn: conn} do - actor = platform_admin_fixture() - target = platform_admin_fixture() + test "leaves the panel promptly when the administrator is suspended", %{ + admin: admin, + conn: conn + } do + actor = user_fixture() + assert {:ok, :granted} = Administration.bootstrap_platform_admin(actor.email) + actor = Repo.get!(Textbin.Accounts.User, actor.id) + actor_scope = Scope.for_user(%{actor | authenticated_at: DateTime.utc_now(:second)}) - assert {:ok, view, _html} = live(log_in_user(conn, target), ~p"/admin") + assert {:ok, view, _html} = live(conn, ~p"/admin") monitor = monitor_proxy(view) assert {:ok, {_target, _tokens}} = - Administration.suspend_user(admin_scope(actor), target, "security response") + Administration.suspend_user(actor_scope, admin, "security response") assert_redirect(view, ~p"/") assert_full_redirect(monitor, ~p"/") @@ -66,14 +145,4 @@ defmodule TextbinWeb.UI.AdminLiveTest do assert_receive {:DOWN, ^monitor_ref, :process, ^proxy_pid, {:shutdown, {:redirect, %{to: ^path}}}} end - - defp platform_admin_fixture do - user = user_fixture() - assert {:ok, :granted} = Administration.bootstrap_platform_admin(user.email) - Repo.reload!(user) - end - - defp admin_scope(user) do - Scope.for_user(%{user | authenticated_at: DateTime.utc_now(:second)}) - end end