From e558486256811ad9fb2b6a55399dd496cabb3d59 Mon Sep 17 00:00:00 2001 From: SimpleTest Date: Sat, 1 Aug 2026 04:54:06 +0300 Subject: [PATCH] Add permission-scoped staff administration workspace --- README.md | 11 +- docs/staff-operations.md | 81 +++++ docs/support-and-content-removal.md | 10 +- docs/trust-safety.md | 9 +- lib/mix/tasks/wnh.staging_full_e2e.ex | 14 +- lib/who_need_help/accounts.ex | 248 ++++++++++++-- lib/who_need_help/accounts/data_export.ex | 8 +- lib/who_need_help/accounts/data_lifecycle.ex | 3 +- .../accounts/staff_permissions.ex | 70 ++++ .../accounts/staff_role_assignment.ex | 28 ++ lib/who_need_help/accounts/user.ex | 11 +- lib/who_need_help/admin.ex | 63 ++++ lib/who_need_help/catalog.ex | 6 +- lib/who_need_help/content_removal.ex | 118 +++++-- lib/who_need_help/content_removal/notice.ex | 10 +- lib/who_need_help/help.ex | 2 +- lib/who_need_help/product_analytics.ex | 2 +- lib/who_need_help/release.ex | 17 +- lib/who_need_help/support.ex | 155 ++++++--- lib/who_need_help/support/support_request.ex | 10 +- lib/who_need_help/trust.ex | 101 ++++-- .../components/admin_components.ex | 139 ++++++++ lib/who_need_help_web/components/layouts.ex | 18 +- .../live/admin_audit_live.ex | 186 +++++++++++ .../live/admin_dashboard_live.ex | 136 ++++++++ .../live/admin_users_live.ex | 293 +++++++++++++++++ lib/who_need_help_web/live/moderation_live.ex | 170 +--------- .../live/product_analytics_live.ex | 34 +- .../live/support_operations_live.ex | 308 ++++++++++++++++-- lib/who_need_help_web/router.ex | 27 +- lib/who_need_help_web/user_auth.ex | 45 ++- ...33_add_staff_rbac_and_case_assignments.exs | 93 ++++++ test/support/fixtures/accounts_fixtures.ex | 24 ++ test/who_need_help/accounts_test.exs | 41 ++- test/who_need_help/activities_test.exs | 10 +- test/who_need_help/product_analytics_test.exs | 5 +- .../support_and_content_removal_test.exs | 67 +++- test/who_need_help/trust_safety_test.exs | 89 ++--- .../controllers/support_controller_test.exs | 15 +- .../live/admin_live_test.exs | 114 +++++++ .../live/mutual_aid_live_test.exs | 23 +- 41 files changed, 2302 insertions(+), 512 deletions(-) create mode 100644 docs/staff-operations.md create mode 100644 lib/who_need_help/accounts/staff_permissions.ex create mode 100644 lib/who_need_help/accounts/staff_role_assignment.ex create mode 100644 lib/who_need_help/admin.ex create mode 100644 lib/who_need_help_web/components/admin_components.ex create mode 100644 lib/who_need_help_web/live/admin_audit_live.ex create mode 100644 lib/who_need_help_web/live/admin_dashboard_live.ex create mode 100644 lib/who_need_help_web/live/admin_users_live.ex create mode 100644 priv/repo/migrations/20260801002933_add_staff_rbac_and_case_assignments.exs create mode 100644 test/who_need_help_web/live/admin_live_test.exs diff --git a/README.md b/README.md index d1e382b..c7d3209 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,10 @@ local Codex CLI authenticated with their ChatGPT subscription. disables rejected device registrations. - Bidirectional discovery blocks, scoped reports, account/request/category moderation, abuse-signal review, and audited moderator access to only the - conversation linked by a report. + conversation linked by a report. A unified staff workspace uses combinable + support, moderator, legal, analyst, and administrator roles; administrators + manage users and staff access while the last active administrator is + protected. - Separate public support and content-removal intake, including moderation appeals, account deletion/data requests, a URL-only TAKE IT DOWN form, verified-contact status links, verification-gated support alerts, and audited @@ -590,8 +593,10 @@ Register and confirm the first account, then explicitly bootstrap it: ``` This succeeds only while no administrator exists and writes an audit event. -After bootstrap, an administrator can manage roles in `/moderation`; the last -administrator cannot demote themselves. For kind, append `kind`: +After bootstrap, an administrator can manage multiple staff roles in +`/admin/users`; the last active administrator cannot remove their own admin +access or be restricted. The complete role matrix and operator workflow are in +[`docs/staff-operations.md`](docs/staff-operations.md). For kind, append `kind`: ```bash ./scripts/bootstrap-admin.sh you@example.com --confirm kind diff --git a/docs/staff-operations.md b/docs/staff-operations.md new file mode 100644 index 0000000..2189d85 --- /dev/null +++ b/docs/staff-operations.md @@ -0,0 +1,81 @@ +# Staff workspace and access control + +Status: implemented. The staff workspace starts at `/admin` and uses +database-checked permissions on every protected route and context operation. +Navigation visibility is only a usability aid; it is not the authorization +boundary. + +## Role model + +A confirmed account can hold any combination of these fixed roles: + +| Role | Access | +| --- | --- | +| `support` | Verified support queue, assignment, conversation, status, and response | +| `moderator` | Reports, scoped evidence, abuse signals, request/activity moderation, user restriction, and category proposals | +| `legal` | Content-removal and TAKE IT DOWN queue, assignment, status, and decision | +| `analyst` | Aggregate privacy-preserving product analytics | +| `admin` | Every staff permission, role administration, user administration, and audit log | + +Permissions are the union of all assigned roles. For example, one account may +hold both `support` and `moderator`. The ordinary user state is represented by +having no staff-role assignments; `user` is not a staff role. + +The matrix is deliberately fixed in +`WhoNeedHelp.Accounts.StaffPermissions`. The UI cannot create arbitrary roles +or attach one-off permissions, which keeps review and auditing unambiguous. + +## Workspaces + +- `/admin` — permission-scoped operational totals and the current account's + staff roles. +- `/admin/users` — account search and filtering, restriction/suspension, and + multi-role assignment. Moderators can act on ordinary accounts; + administrators can also act on staff accounts and change roles. +- `/support/operations` — support and legal queues. Each section is rendered + only when the operator has its permission. Cases can be filtered and assigned + only to an active account that can manage the matching queue. +- `/moderation` — reports, scoped evidence, abuse signals, hidden content, and + category proposals. +- `/analytics` — aggregate metrics for analysts and administrators. +- `/admin/audit` — administrator-only, cursor-paginated audit records with + actor, action, target, timestamp, and stored metadata. + +## Administrative safeguards + +- Role changes require an active administrator and a session authenticated in + the preceding ten minutes. A stale session receives a forbidden result and + must authenticate again. +- The final active administrator cannot lose the `admin` role and cannot be + restricted or suspended. +- A staff account can be restricted or suspended only by an administrator. + A moderator cannot change another staff account. +- An operator cannot restrict or suspend their own account. +- Suspending an account deletes its login tokens, disconnects active LiveView + sessions, and stops/deletes current live-location state. +- Changing staff roles disconnects the affected account's active sessions so + the next session loads the new permission set. +- Assignment is validated on the server. A support case cannot be assigned to + a legal-only or ordinary account, and a legal case cannot be assigned to a + support-only or ordinary account. +- Sensitive changes record audit events. Audit rows are not editable from the + staff UI. + +## First administrator + +After the first account has registered and confirmed its email, bootstrap the +initial administrator exactly once: + +```bash +./scripts/bootstrap-admin.sh you@example.com --confirm +``` + +The command refuses to run after an administrator exists and records an audit +event. All subsequent role changes are made from `/admin/users`. + +## Operational checks + +Before granting access, confirm that the person needs the smallest applicable +role or role combination. After a change, verify the corresponding audit event +and ask the operator to start a new session. Regularly review unassigned support +and legal queues, suspended staff accounts, and the audit log. diff --git a/docs/support-and-content-removal.md b/docs/support-and-content-removal.md index 92aec8f..4a84c3a 100644 --- a/docs/support-and-content-removal.md +++ b/docs/support-and-content-removal.md @@ -17,9 +17,13 @@ Who Need Help deliberately separates three mechanisms: infringe a right. TAKE IT DOWN notices have a dedicated public form and a separate regime value in the removal queue. -The operator workspace is `/support/operations`. It is protected by the same -database-checked moderator or administrator authorization as the existing -moderation workspace. Every creation and decision records an audit event. +The operator workspace is `/support/operations`. Support and legal access are +separate database-checked permissions that can be combined on one account; +administrators have both. Each queue supports search, status/type filtering, +assignee filtering, cursor pagination, and assignment only to an active member +of the corresponding team. Every creation and operator decision records an +audit event. The complete role matrix is documented in +`docs/staff-operations.md`. ## Public routes diff --git a/docs/trust-safety.md b/docs/trust-safety.md index e80ec32..d9402a1 100644 --- a/docs/trust-safety.md +++ b/docs/trust-safety.md @@ -122,8 +122,10 @@ does not reveal its private chat. Only a report targeting a specific Activity message permits the moderator to load that Activity's group conversation, and the evidence access audit records the Activity and message count. -Report, request, signal, category, role, and account moderation are role -protected. The local Codex +Report, request, signal, category, role, and account moderation are protected by +the multi-role permission matrix described in `docs/staff-operations.md`. +Support, moderation, legal, and analytics access can be combined on one +account; administrators receive every permission. The local Codex batch receives proposal text and aggregate vote counts only. It receives no private messages, email addresses, OAuth tokens, exact coordinates, or raw tracking routes. @@ -142,7 +144,8 @@ Reports have `open`, `reviewing`, `resolved`, and `dismissed` states. A severe report can hide a request and temporarily restrict an account pending review. Suspension invalidates that account's login sessions. The first administrator requires an explicit one-time operational bootstrap, and later role changes are -audited; the last administrator cannot demote themselves. +audited; the last active administrator cannot remove their own admin access or +be restricted. The operator must publish jurisdiction-specific emergency contacts, privacy notice, prohibited-items policy, and data-retention policy before public launch. diff --git a/lib/mix/tasks/wnh.staging_full_e2e.ex b/lib/mix/tasks/wnh.staging_full_e2e.ex index 876c0d3..b3191a7 100644 --- a/lib/mix/tasks/wnh.staging_full_e2e.ex +++ b/lib/mix/tasks/wnh.staging_full_e2e.ex @@ -4,7 +4,7 @@ defmodule Mix.Tasks.Wnh.StagingFullE2e do import Ecto.Query alias Oban.Job - alias WhoNeedHelp.Accounts.{SocialIdentity, User} + alias WhoNeedHelp.Accounts.{SocialIdentity, StaffRoleAssignment, User} alias WhoNeedHelp.Activities.{Activity, Participant} alias WhoNeedHelp.Activities.Message, as: ActivityMessage alias WhoNeedHelp.Catalog.{CategoryProposal, CategoryVote} @@ -94,17 +94,20 @@ defmodule Mix.Tasks.Wnh.StagingFullE2e do {:ok, users} = Repo.transaction(fn -> Map.new(@precreated_roles, fn role -> - user_role = if role == "admin", do: :admin, else: :user - user = insert_user!( context.emails[role], display_name(role), password_hash, - user_role, now ) + if role == "admin" do + %StaffRoleAssignment{} + |> StaffRoleAssignment.changeset(%{user_id: user.id, role: :admin}) + |> Repo.insert!() + end + {role, %{"id" => user.id, "email" => user.email}} end) end) @@ -500,7 +503,7 @@ defmodule Mix.Tasks.Wnh.StagingFullE2e do defp delete_count(query), do: query |> Repo.delete_all() |> elem(0) - defp insert_user!(email, display_name, password_hash, role, now) do + defp insert_user!(email, display_name, password_hash, now) do %User{} |> User.registration_changeset(%{ "email" => email, @@ -510,7 +513,6 @@ defmodule Mix.Tasks.Wnh.StagingFullE2e do }) |> Ecto.Changeset.put_change(:hashed_password, password_hash) |> Ecto.Changeset.put_change(:confirmed_at, now) - |> Ecto.Changeset.put_change(:role, role) |> Repo.insert!() end diff --git a/lib/who_need_help/accounts.ex b/lib/who_need_help/accounts.ex index a5f80b0..0cf3aee 100644 --- a/lib/who_need_help/accounts.ex +++ b/lib/who_need_help/accounts.ex @@ -11,6 +11,8 @@ defmodule WhoNeedHelp.Accounts do AuthIdentity, Scope, SocialIdentity, + StaffPermissions, + StaffRoleAssignment, User, UserNotifier, UserToken @@ -154,46 +156,85 @@ defmodule WhoNeedHelp.Accounts do ) end - def moderator?(%User{role: role}), do: role in [:moderator, :admin] - def moderator?(_user), do: false + def staff_roles(%User{id: id}) do + StaffRoleAssignment + |> where([assignment], assignment.user_id == ^id) + |> order_by([assignment], asc: assignment.role) + |> select([assignment], assignment.role) + |> Repo.all() + end - def moderator_authorized?(%User{id: id, role: role}) - when role in [:moderator, :admin], - do: + def loaded_staff_roles(%User{staff_role_assignments: %Ecto.Association.NotLoaded{}}), do: [] + + def loaded_staff_roles(%User{staff_role_assignments: assignments}) when is_list(assignments), + do: Enum.map(assignments, & &1.role) + + def loaded_staff_roles(_user), do: [] + + def permission?(%User{moderation_status: :active} = user, permission) do + StaffPermissions.allowed?(loaded_staff_roles(user), permission) + end + + def permission?(_user, _permission), do: false + + def authorized?(%User{id: id}, permission) do + allowed_roles = StaffPermissions.roles_for_permission(permission) + + allowed_roles != [] and + Repo.exists?( + from assignment in StaffRoleAssignment, + join: user in User, + on: user.id == assignment.user_id, + where: + assignment.user_id == ^id and assignment.role in ^allowed_roles and + user.moderation_status == :active + ) + end + + def authorized?(_user, _permission), do: false + + def authorized_user_id?(user_id, permission) do + with {:ok, user_id} <- cast_id(user_id) do + allowed_roles = StaffPermissions.roles_for_permission(permission) + + allowed_roles != [] and Repo.exists?( - from user in User, + from assignment in StaffRoleAssignment, + join: user in User, + on: user.id == assignment.user_id, where: - user.id == ^id and user.role in [:moderator, :admin] and + assignment.user_id == ^user_id and assignment.role in ^allowed_roles and user.moderation_status == :active ) + else + _ -> false + end + end - def moderator_authorized?(_user), do: false - - def admin?(%User{role: :admin}), do: true - def admin?(_user), do: false - - def admin_authorized?(%User{id: id, role: :admin}), - do: - Repo.exists?( - from user in User, - where: user.id == ^id and user.role == :admin and user.moderation_status == :active - ) - - def admin_authorized?(_user), do: false + def staff?(user), do: permission?(user, :staff_access) + def staff_authorized?(user), do: authorized?(user, :staff_access) + def moderator?(user), do: permission?(user, :moderation_view) + def moderator_authorized?(user), do: authorized?(user, :moderation_view) + def admin?(user), do: :admin in loaded_staff_roles(user) + def admin_authorized?(user), do: authorized?(user, :staff_manage) def list_users_for_moderation(%Scope{user: user}) do paginate_users_for_moderation(%Scope{user: user}).entries end def paginate_users_for_moderation(%Scope{user: user}, options \\ []) do - if moderator_authorized?(user) do + if authorized?(user, :users_view) do limit = Pagination.limit(options) cursor = Pagination.cursor(options) User + |> maybe_user_status(Keyword.get(options, :status)) + |> maybe_user_role(Keyword.get(options, :role)) + |> maybe_user_search(Keyword.get(options, :search)) |> before_moderation_user(cursor) |> order_by([user], desc: user.inserted_at, desc: user.id) |> limit(^(limit + 1)) + |> preload(:staff_role_assignments) |> Repo.all() |> Pagination.page(limit, &{&1.inserted_at, &1.id}) else @@ -203,7 +244,7 @@ defmodule WhoNeedHelp.Accounts do def moderate_user(%User{} = moderator, user_id, attrs) do with {:ok, user_id} <- cast_id(user_id), - true <- moderator_authorized?(moderator) do + true <- authorized?(moderator, :users_moderate) do Repo.transact(fn -> active_admins = lock_active_admins() user = User |> where([user], user.id == ^user_id) |> lock("FOR UPDATE") |> Repo.one() @@ -212,14 +253,14 @@ defmodule WhoNeedHelp.Accounts do requested_status = attrs["moderation_status"] || attrs[:moderation_status] cond do - user.role in [:moderator, :admin] and not admin_authorized?(moderator) -> + user_has_staff_roles?(user.id) and not admin_authorized?(moderator) -> {:error, :forbidden} user.id == moderator.id and requested_status in [:restricted, :suspended, "restricted", "suspended"] -> {:error, :cannot_restrict_self} - user.role == :admin and user.moderation_status == :active and + user_has_role?(user.id, :admin) and user.moderation_status == :active and requested_status not in [:active, "active"] and length(active_admins) == 1 -> {:error, :last_admin} @@ -252,21 +293,54 @@ defmodule WhoNeedHelp.Accounts do end end - def change_user_role(%User{} = admin, user_id, attrs) do + def set_staff_roles(%User{} = admin, user_id, roles) when is_list(roles) do with {:ok, user_id} <- cast_id(user_id), - true <- admin_authorized?(admin) do + true <- admin_authorized?(admin), + true <- sudo_mode?(admin, -10), + {:ok, roles} <- normalize_staff_roles(roles) do Repo.transact(fn -> active_admins = lock_active_admins() user = User |> where([user], user.id == ^user_id) |> lock("FOR UPDATE") |> Repo.one() if user do - requested_role = attrs["role"] || attrs[:role] + current_roles = staff_roles(user) - if user.role == :admin and user.moderation_status == :active and - requested_role not in [:admin, "admin"] and length(active_admins) == 1 do + if :admin in current_roles and user.moderation_status == :active and + :admin not in roles and length(active_admins) == 1 do {:error, :last_admin} else - user |> User.role_changeset(attrs) |> Repo.update() + if roles == [] do + Repo.delete_all( + from assignment in StaffRoleAssignment, + where: assignment.user_id == ^user.id + ) + else + StaffRoleAssignment + |> where( + [assignment], + assignment.user_id == ^user.id and assignment.role not in ^roles + ) + |> Repo.delete_all() + end + + roles + |> Enum.reject(&(&1 in current_roles)) + |> Enum.each(fn role -> + %StaffRoleAssignment{} + |> StaffRoleAssignment.changeset(%{ + user_id: user.id, + role: role, + assigned_by_id: admin.id + }) + |> Repo.insert!() + end) + + session_tokens = + UserToken + |> where([token], token.user_id == ^user.id and token.context == "session") + |> Repo.all() + + {:ok, %{user: preload_staff_roles(user), session_tokens: session_tokens}} end else {:error, :not_found} @@ -275,17 +349,76 @@ defmodule WhoNeedHelp.Accounts do else false -> {:error, :forbidden} {:error, :not_found} = error -> error + {:error, :invalid_roles} = error -> error end end + def set_staff_roles(_admin, _user_id, _roles), do: {:error, :invalid_roles} + defp lock_active_admins do - User - |> where([user], user.role == :admin and user.moderation_status == :active) - |> order_by([user], asc: user.id) + active_user_ids = + from user in User, + where: user.moderation_status == :active, + select: user.id + + StaffRoleAssignment + |> where( + [assignment], + assignment.role == :admin and assignment.user_id in subquery(active_user_ids) + ) + |> order_by([assignment], asc: assignment.user_id) |> lock("FOR UPDATE") |> Repo.all() end + def list_staff_for_permission(permission) do + allowed_roles = StaffPermissions.roles_for_permission(permission) + + User + |> join(:inner, [user], assignment in StaffRoleAssignment, + on: assignment.user_id == user.id and assignment.role in ^allowed_roles + ) + |> where([user], user.moderation_status == :active) + |> order_by([user], asc: user.display_name, asc: user.email) + |> distinct(true) + |> preload(:staff_role_assignments) + |> Repo.all() + end + + def preload_staff_roles(%User{} = user), + do: Repo.preload(user, :staff_role_assignments, force: true) + + defp user_has_staff_roles?(user_id) do + Repo.exists?(from assignment in StaffRoleAssignment, where: assignment.user_id == ^user_id) + end + + defp user_has_role?(user_id, role) do + Repo.exists?( + from assignment in StaffRoleAssignment, + where: assignment.user_id == ^user_id and assignment.role == ^role + ) + end + + defp normalize_staff_roles(roles) do + roles = + roles + |> Enum.map(fn + role when is_atom(role) -> + role + + role when is_binary(role) -> + Enum.find(StaffRoleAssignment.roles(), &(Atom.to_string(&1) == role)) + + _other -> + nil + end) + |> Enum.uniq() + + if Enum.all?(roles, &StaffPermissions.valid_role?/1), + do: {:ok, Enum.sort(roles)}, + else: {:error, :invalid_roles} + end + ## User registration @doc """ @@ -809,7 +942,11 @@ defmodule WhoNeedHelp.Accounts do """ def get_user_by_session_token(token) do {:ok, query} = UserToken.verify_session_token_query(token) - Repo.one(query) + + case Repo.one(query) do + {%User{} = user, inserted_at} -> {preload_staff_roles(user), inserted_at} + nil -> nil + end end @doc """ @@ -967,6 +1104,49 @@ defmodule WhoNeedHelp.Accounts do ) end + defp maybe_user_status(query, value) when value in [nil, ""], do: query + + defp maybe_user_status(query, value) do + case Ecto.Enum.cast_value(User, :moderation_status, value) do + {:ok, status} -> where(query, [user], user.moderation_status == ^status) + :error -> where(query, [user], false) + end + end + + defp maybe_user_role(query, value) when value in [nil, ""], do: query + + defp maybe_user_role(query, "user") do + from user in query, + left_join: assignment in StaffRoleAssignment, + on: assignment.user_id == user.id, + where: is_nil(assignment.id) + end + + defp maybe_user_role(query, value) do + case Enum.find(StaffPermissions.roles(), &(Atom.to_string(&1) == to_string(value))) do + nil -> + where(query, [user], false) + + role -> + from user in query, + join: assignment in StaffRoleAssignment, + on: assignment.user_id == user.id and assignment.role == ^role + end + end + + defp maybe_user_search(query, value) when value in [nil, ""], do: query + + defp maybe_user_search(query, value) do + term = value |> String.trim() |> String.replace("%", "") |> String.replace("_", "") + pattern = "%#{term}%" + + where( + query, + [user], + ilike(user.email, ^pattern) or ilike(user.display_name, ^pattern) + ) + end + defp cast_id(value) do case Ecto.UUID.cast(value) do {:ok, id} -> {:ok, id} diff --git a/lib/who_need_help/accounts/data_export.ex b/lib/who_need_help/accounts/data_export.ex index 67e5051..50e4b85 100644 --- a/lib/who_need_help/accounts/data_export.ex +++ b/lib/who_need_help/accounts/data_export.ex @@ -9,6 +9,7 @@ defmodule WhoNeedHelp.Accounts.DataExport do import Ecto.Query + alias WhoNeedHelp.Accounts alias WhoNeedHelp.Accounts.{AuthIdentity, Scope, SocialIdentity, User} alias WhoNeedHelp.Activities.{Activity, Message, Participant} alias WhoNeedHelp.Catalog.{CategoryProposal, CategoryVote} @@ -31,7 +32,8 @@ defmodule WhoNeedHelp.Accounts.DataExport do "version" => @version, "exported_at" => exported_at, "account" => - record(user, [ + user + |> record([ :id, :email, :display_name, @@ -39,14 +41,14 @@ defmodule WhoNeedHelp.Accounts.DataExport do :locale, :location_visibility, :direct_message_policy, - :role, :moderation_status, :tip_url, :confirmed_at, :accepted_terms_at, :inserted_at, :updated_at - ]), + ]) + |> Map.put(:staff_roles, Accounts.staff_roles(user)), "authentication_identities" => owned(AuthIdentity, :user_id, user_id, [ :id, diff --git a/lib/who_need_help/accounts/data_lifecycle.ex b/lib/who_need_help/accounts/data_lifecycle.ex index a2f5c06..1086c46 100644 --- a/lib/who_need_help/accounts/data_lifecycle.ex +++ b/lib/who_need_help/accounts/data_lifecycle.ex @@ -10,6 +10,7 @@ defmodule WhoNeedHelp.Accounts.DataLifecycle do import Ecto.Query + alias WhoNeedHelp.Accounts alias WhoNeedHelp.Accounts.{Scope, User} alias WhoNeedHelp.Activities.{Activity, Participant} alias WhoNeedHelp.Help.{Assignment, HelpRequest} @@ -45,7 +46,7 @@ defmodule WhoNeedHelp.Accounts.DataLifecycle do case_id: request.id, reference: request.reference, account_id: user.id, - account_role: user.role, + account_staff_roles: Accounts.staff_roles(user), contact_verified: true, assessed_by: moderator.id, assessed_at: DateTime.utc_now(:second), diff --git a/lib/who_need_help/accounts/staff_permissions.ex b/lib/who_need_help/accounts/staff_permissions.ex new file mode 100644 index 0000000..f8d4e81 --- /dev/null +++ b/lib/who_need_help/accounts/staff_permissions.ex @@ -0,0 +1,70 @@ +defmodule WhoNeedHelp.Accounts.StaffPermissions do + @moduledoc """ + Central, deliberately fixed staff role and permission policy. + + Roles can be combined. Administrators receive every permission and are the + only role allowed to change staff access or inspect the audit trail. + """ + + @roles [:support, :moderator, :legal, :analyst, :admin] + + @permissions [ + :staff_access, + :dashboard_view, + :support_view, + :support_manage, + :moderation_view, + :moderation_manage, + :moderation_evidence_view, + :users_view, + :users_moderate, + :categories_manage, + :legal_view, + :legal_manage, + :analytics_view, + :staff_manage, + :audit_view + ] + + @role_permissions %{ + support: [:staff_access, :dashboard_view, :support_view, :support_manage], + moderator: [ + :staff_access, + :dashboard_view, + :moderation_view, + :moderation_manage, + :moderation_evidence_view, + :users_view, + :users_moderate, + :categories_manage + ], + legal: [:staff_access, :dashboard_view, :legal_view, :legal_manage], + analyst: [:staff_access, :dashboard_view, :analytics_view], + admin: @permissions + } + + def roles, do: @roles + def permissions, do: @permissions + def valid_role?(role), do: role in @roles + + def permissions_for_roles(roles) when is_list(roles) do + roles + |> Enum.flat_map(&Map.get(@role_permissions, &1, [])) + |> MapSet.new() + end + + def allowed?(roles, permission) when permission in @permissions do + roles + |> permissions_for_roles() + |> MapSet.member?(permission) + end + + def allowed?(_roles, _permission), do: false + + def roles_for_permission(permission) when permission in @permissions do + @roles + |> Enum.filter(fn role -> permission in Map.fetch!(@role_permissions, role) end) + end + + def roles_for_permission(_permission), do: [] +end diff --git a/lib/who_need_help/accounts/staff_role_assignment.ex b/lib/who_need_help/accounts/staff_role_assignment.ex new file mode 100644 index 0000000..b23b4ce --- /dev/null +++ b/lib/who_need_help/accounts/staff_role_assignment.ex @@ -0,0 +1,28 @@ +defmodule WhoNeedHelp.Accounts.StaffRoleAssignment do + use Ecto.Schema + import Ecto.Changeset + + @roles [:support, :moderator, :legal, :analyst, :admin] + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "staff_role_assignments" do + field :role, Ecto.Enum, values: @roles + belongs_to :user, WhoNeedHelp.Accounts.User + belongs_to :assigned_by, WhoNeedHelp.Accounts.User + + timestamps(type: :utc_datetime) + end + + def roles, do: @roles + + def changeset(assignment, attrs) do + assignment + |> cast(attrs, [:user_id, :role, :assigned_by_id]) + |> validate_required([:user_id, :role]) + |> foreign_key_constraint(:user_id) + |> foreign_key_constraint(:assigned_by_id) + |> unique_constraint([:user_id, :role]) + end +end diff --git a/lib/who_need_help/accounts/user.ex b/lib/who_need_help/accounts/user.ex index 76e46af..1d91719 100644 --- a/lib/who_need_help/accounts/user.ex +++ b/lib/who_need_help/accounts/user.ex @@ -24,8 +24,6 @@ defmodule WhoNeedHelp.Accounts.User do values: [:everyone, :verified_accounts, :completed_help_users, :nobody], default: :verified_accounts - field :role, Ecto.Enum, values: [:user, :moderator, :admin], default: :user - field :moderation_status, Ecto.Enum, values: [:active, :restricted, :suspended], default: :active @@ -40,6 +38,9 @@ defmodule WhoNeedHelp.Accounts.User do has_many :nearby_subscriptions, WhoNeedHelp.Notifications.NearbySubscription has_many :push_devices, WhoNeedHelp.Notifications.PushDevice + has_many :staff_role_assignments, WhoNeedHelp.Accounts.StaffRoleAssignment, + preload_order: [asc: :role] + timestamps(type: :utc_datetime) end @@ -115,12 +116,6 @@ defmodule WhoNeedHelp.Accounts.User do end end - def role_changeset(user, attrs) do - user - |> cast(attrs, [:role]) - |> validate_required([:role]) - end - defp validate_url(changeset, field) do validate_change(changeset, field, fn ^field, value -> case URI.parse(value) do diff --git a/lib/who_need_help/admin.ex b/lib/who_need_help/admin.ex new file mode 100644 index 0000000..6437037 --- /dev/null +++ b/lib/who_need_help/admin.ex @@ -0,0 +1,63 @@ +defmodule WhoNeedHelp.Admin do + @moduledoc "Read-only operational summaries for the staff workspace." + + import Ecto.Query + + alias WhoNeedHelp.Accounts + alias WhoNeedHelp.Accounts.{Scope, StaffRoleAssignment, User} + alias WhoNeedHelp.Catalog.CategoryProposal + alias WhoNeedHelp.ContentRemoval.Notice + alias WhoNeedHelp.Repo + alias WhoNeedHelp.Support.SupportRequest + alias WhoNeedHelp.Trust.{AbuseSignal, Report} + + def dashboard(%Scope{user: user}) do + if Accounts.authorized?(user, :dashboard_view) do + %{} + |> maybe_put(user, :support_view, :support_open, fn -> + count(SupportRequest, [status: [:open, :reviewing, :waiting_for_requester]], true) + end) + |> maybe_put(user, :legal_view, :legal_open, fn -> + count(Notice, status: [:open, :urgent_review, :reviewing, :needs_information]) + end) + |> maybe_put(user, :moderation_view, :reports_open, fn -> + count(Report, status: [:open, :reviewing]) + end) + |> maybe_put(user, :moderation_view, :signals_open, fn -> + count(AbuseSignal, status: [:open]) + end) + |> maybe_put(user, :categories_manage, :category_proposals_open, fn -> + count(CategoryProposal, status: [:open]) + end) + |> maybe_put(user, :users_view, :users_total, fn -> Repo.aggregate(User, :count) end) + |> maybe_put(user, :staff_manage, :staff_total, fn -> + Repo.one( + from assignment in StaffRoleAssignment, + select: count(assignment.user_id, :distinct) + ) + end) + else + %{} + end + end + + defp maybe_put(summary, user, permission, key, value_fun) do + if Accounts.authorized?(user, permission), + do: Map.put(summary, key, value_fun.()), + else: summary + end + + defp count(schema, filters, verified_only \\ false) do + query = + Enum.reduce(filters, schema, fn + {:status, statuses}, query -> where(query, [record], record.status in ^statuses) + end) + + query = + if verified_only, + do: where(query, [record], not is_nil(record.contact_verified_at)), + else: query + + Repo.aggregate(query, :count) + end +end diff --git a/lib/who_need_help/catalog.ex b/lib/who_need_help/catalog.ex index 0acc341..579b6ca 100644 --- a/lib/who_need_help/catalog.ex +++ b/lib/who_need_help/catalog.ex @@ -185,7 +185,7 @@ defmodule WhoNeedHelp.Catalog do end def paginate_proposals_for_moderation(%Scope{user: user}, options \\ []) do - if Accounts.moderator_authorized?(user) do + if Accounts.authorized?(user, :categories_manage) do limit = Pagination.limit(options) cursor = Pagination.cursor(options) @@ -204,7 +204,7 @@ defmodule WhoNeedHelp.Catalog do def approve_proposal(%Scope{user: moderator}, proposal_id, category_attrs) do with {:ok, proposal_id} <- cast_id(proposal_id), - true <- Accounts.moderator_authorized?(moderator) do + true <- Accounts.authorized?(moderator, :categories_manage) do Repo.transact(fn -> proposal = locked_proposal(proposal_id) @@ -267,7 +267,7 @@ defmodule WhoNeedHelp.Catalog do defp moderate_proposal(moderator, proposal_id, status, merged_into_id, note) do with {:ok, proposal_id} <- cast_id(proposal_id), - true <- Accounts.moderator_authorized?(moderator) do + true <- Accounts.authorized?(moderator, :categories_manage) do Repo.transact(fn -> proposal = locked_proposal(proposal_id) diff --git a/lib/who_need_help/content_removal.ex b/lib/who_need_help/content_removal.ex index f07c462..9049bc1 100644 --- a/lib/who_need_help/content_removal.ex +++ b/lib/who_need_help/content_removal.ex @@ -106,16 +106,19 @@ defmodule WhoNeedHelp.ContentRemoval do end def paginate_for_staff(%Scope{user: user}, options \\ []) do - if Accounts.moderator_authorized?(user) do + if Accounts.authorized?(user, :legal_view) do limit = Pagination.limit(options) cursor = Pagination.cursor(options) Notice |> maybe_regime(Keyword.get(options, :regime)) + |> maybe_status(Keyword.get(options, :status)) + |> maybe_assignee(Keyword.get(options, :assigned_to_id), user.id) + |> maybe_search(Keyword.get(options, :search)) |> before(cursor) |> order_by([notice], desc: notice.inserted_at, desc: notice.id) |> limit(^(limit + 1)) - |> preload([:requester, :reviewed_by]) + |> preload([:requester, :reviewed_by, :assigned_to]) |> Repo.all() |> Pagination.page(limit, &{&1.inserted_at, &1.id}) else @@ -125,41 +128,42 @@ defmodule WhoNeedHelp.ContentRemoval do def moderate(%Scope{user: moderator}, id, attrs) do with {:ok, id} <- Ecto.UUID.cast(id), - true <- Accounts.moderator_authorized?(moderator) do - attrs = - attrs - |> normalize_keys() - |> Map.merge(%{ - "reviewed_at" => DateTime.utc_now(:second), - "reviewed_by_id" => moderator.id - }) + true <- Accounts.authorized?(moderator, :legal_manage) do + with {:ok, attrs} <- normalize_assignment(normalize_keys(attrs), :legal_manage) do + attrs = + Map.merge(attrs, %{ + "reviewed_at" => DateTime.utc_now(:second), + "reviewed_by_id" => moderator.id + }) - Repo.transact(fn -> - notice = - Notice - |> where([notice], notice.id == ^id) - |> lock("FOR UPDATE") - |> Repo.one() + Repo.transact(fn -> + notice = + Notice + |> where([notice], notice.id == ^id) + |> lock("FOR UPDATE") + |> Repo.one() - if notice do - with {:ok, notice} <- notice |> Notice.moderation_changeset(attrs) |> Repo.update(), - {:ok, _audit} <- - Trust.audit( - moderator.id, - "content_removal_notice.moderated", - "content_removal_notice", - notice.id, - %{ - "status" => to_string(notice.status) - } - ) do - {:ok, notice} + if notice do + with {:ok, notice} <- notice |> Notice.moderation_changeset(attrs) |> Repo.update(), + {:ok, _audit} <- + Trust.audit( + moderator.id, + "content_removal_notice.moderated", + "content_removal_notice", + notice.id, + %{ + "status" => to_string(notice.status), + "assigned_to_id" => notice.assigned_to_id + } + ) do + {:ok, Repo.preload(notice, :assigned_to, force: true)} + end + else + {:error, :not_found} end - else - {:error, :not_found} - end - end) - |> notify_decision() + end) + |> notify_decision() + end else false -> {:error, :forbidden} _ -> {:error, :not_found} @@ -230,8 +234,54 @@ defmodule WhoNeedHelp.ContentRemoval do end defp maybe_regime(query, nil), do: query + defp maybe_regime(query, ""), do: query defp maybe_regime(query, regime), do: where(query, [notice], notice.regime == ^regime) + defp maybe_status(query, nil), do: query + defp maybe_status(query, ""), do: query + defp maybe_status(query, status), do: where(query, [notice], notice.status == ^status) + + defp maybe_assignee(query, nil, _current_user_id), do: query + defp maybe_assignee(query, "", _current_user_id), do: query + + defp maybe_assignee(query, "unassigned", _current_user_id), + do: where(query, [n], is_nil(n.assigned_to_id)) + + defp maybe_assignee(query, "mine", current_user_id), + do: where(query, [n], n.assigned_to_id == ^current_user_id) + + defp maybe_assignee(query, assignee_id, _current_user_id), + do: where(query, [n], n.assigned_to_id == ^assignee_id) + + defp maybe_search(query, value) when value in [nil, ""], do: query + + defp maybe_search(query, value) do + term = value |> String.trim() |> String.replace("%", "") |> String.replace("_", "") + pattern = "%#{term}%" + + where( + query, + [notice], + ilike(notice.reference, ^pattern) or ilike(notice.contact_email, ^pattern) or + ilike(notice.submitter_name, ^pattern) + ) + end + + defp normalize_assignment(attrs, permission) do + case Map.fetch(attrs, "assigned_to_id") do + :error -> + {:ok, attrs} + + {:ok, value} when value in [nil, ""] -> + {:ok, Map.put(attrs, "assigned_to_id", nil)} + + {:ok, user_id} -> + if Accounts.authorized_user_id?(user_id, permission), + do: {:ok, attrs}, + else: {:error, :invalid_assignee} + end + end + defp maybe_use_user_email(attrs, %User{email: email}) do Map.put(attrs, "contact_email", email) end diff --git a/lib/who_need_help/content_removal/notice.ex b/lib/who_need_help/content_removal/notice.ex index 558476f..ee06d4b 100644 --- a/lib/who_need_help/content_removal/notice.ex +++ b/lib/who_need_help/content_removal/notice.ex @@ -55,6 +55,7 @@ defmodule WhoNeedHelp.ContentRemoval.Notice do field :decision_sent_at, :utc_datetime belongs_to :requester, WhoNeedHelp.Accounts.User belongs_to :reviewed_by, WhoNeedHelp.Accounts.User + belongs_to :assigned_to, WhoNeedHelp.Accounts.User timestamps(type: :utc_datetime) end @@ -103,7 +104,14 @@ defmodule WhoNeedHelp.ContentRemoval.Notice do def moderation_changeset(notice, attrs) do notice - |> cast(attrs, [:status, :resolution_note, :reviewed_at, :reviewed_by_id, :decision_sent_at]) + |> cast(attrs, [ + :status, + :resolution_note, + :reviewed_at, + :reviewed_by_id, + :decision_sent_at, + :assigned_to_id + ]) |> validate_required([:status, :reviewed_at, :reviewed_by_id]) |> validate_length(:resolution_note, max: 10_000) end diff --git a/lib/who_need_help/help.ex b/lib/who_need_help/help.ex index 3ffc5c5..7d47871 100644 --- a/lib/who_need_help/help.ex +++ b/lib/who_need_help/help.ex @@ -342,7 +342,7 @@ defmodule WhoNeedHelp.Help do request.requester_id == user.id -> {:ok, request} - Accounts.moderator_authorized?(user) -> + Accounts.authorized?(user, :moderation_view) -> {:ok, request} not is_nil(request.hidden_at) -> diff --git a/lib/who_need_help/product_analytics.ex b/lib/who_need_help/product_analytics.ex index c03fc08..7af0993 100644 --- a/lib/who_need_help/product_analytics.ex +++ b/lib/who_need_help/product_analytics.ex @@ -71,7 +71,7 @@ defmodule WhoNeedHelp.ProductAnalytics do def increment_for_user(_user, _metric, _dimension), do: {:error, :invalid_user} def paginate(%Scope{user: user}, options \\ []) do - if Accounts.moderator_authorized?(user) do + if Accounts.authorized?(user, :analytics_view) do limit = Pagination.limit(options) cursor = Pagination.cursor(options) diff --git a/lib/who_need_help/release.ex b/lib/who_need_help/release.ex index f007609..9942b45 100644 --- a/lib/who_need_help/release.ex +++ b/lib/who_need_help/release.ex @@ -33,7 +33,9 @@ defmodule WhoNeedHelp.Release do admin_count = WhoNeedHelp.Repo.aggregate( - from(user in WhoNeedHelp.Accounts.User, where: user.role == :admin), + from(assignment in WhoNeedHelp.Accounts.StaffRoleAssignment, + where: assignment.role == :admin + ), :count ) @@ -42,13 +44,16 @@ defmodule WhoNeedHelp.Release do {:error, :admin_already_exists} user = WhoNeedHelp.Repo.get_by(WhoNeedHelp.Accounts.User, email: email) -> - with {:ok, user} <- - user - |> WhoNeedHelp.Accounts.User.role_changeset(%{role: :admin}) - |> WhoNeedHelp.Repo.update(), + with {:ok, _assignment} <- + %WhoNeedHelp.Accounts.StaffRoleAssignment{} + |> WhoNeedHelp.Accounts.StaffRoleAssignment.changeset(%{ + user_id: user.id, + role: :admin + }) + |> WhoNeedHelp.Repo.insert(), {:ok, _audit} <- WhoNeedHelp.Trust.audit(nil, "user.admin_bootstrapped", "user", user.id) do - {:ok, %{id: user.id, email: user.email, role: user.role}} + {:ok, %{id: user.id, email: user.email, roles: [:admin]}} end true -> diff --git a/lib/who_need_help/support.ex b/lib/who_need_help/support.ex index 2cc0577..38c6160 100644 --- a/lib/who_need_help/support.ex +++ b/lib/who_need_help/support.ex @@ -141,19 +141,23 @@ defmodule WhoNeedHelp.Support do end def paginate_for_staff(%Scope{user: user}, options \\ []) do - if Accounts.moderator_authorized?(user) do + if Accounts.authorized?(user, :support_view) do limit = Pagination.limit(options) cursor = Pagination.cursor(options) SupportRequest |> where([request], not is_nil(request.contact_verified_at)) |> maybe_kind(Keyword.get(options, :kind)) + |> maybe_status(Keyword.get(options, :status)) + |> maybe_assignee(Keyword.get(options, :assigned_to_id), user.id) + |> maybe_search(Keyword.get(options, :search)) |> before(cursor) |> order_by([request], desc: request.inserted_at, desc: request.id) |> limit(^(limit + 1)) |> preload([ :requester, :reviewed_by, + :assigned_to, conversation_messages: :sender, status_events: :actor ]) @@ -166,62 +170,65 @@ defmodule WhoNeedHelp.Support do def moderate(%Scope{user: moderator}, id, attrs) do with {:ok, id} <- Ecto.UUID.cast(id), - true <- Accounts.moderator_authorized?(moderator) do + true <- Accounts.authorized?(moderator, :support_manage) do attrs = normalize_keys(attrs) response = normalize_message(attrs["response"] || attrs["resolution_note"]) - attrs = - attrs - |> Map.delete("response") - |> maybe_put_resolution(response) - |> Map.merge(%{ - "reviewed_at" => DateTime.utc_now(:second), - "reviewed_by_id" => moderator.id - }) + with {:ok, attrs} <- normalize_assignment(attrs, :support_manage) do + attrs = + attrs + |> Map.delete("response") + |> maybe_put_resolution(response) + |> Map.merge(%{ + "reviewed_at" => DateTime.utc_now(:second), + "reviewed_by_id" => moderator.id + }) - Repo.transact(fn -> - request = - SupportRequest - |> where([request], request.id == ^id and not is_nil(request.contact_verified_at)) - |> lock("FOR UPDATE") - |> Repo.one() + Repo.transact(fn -> + request = + SupportRequest + |> where([request], request.id == ^id and not is_nil(request.contact_verified_at)) + |> lock("FOR UPDATE") + |> Repo.one() - if request do - previous_status = request.status + if request do + previous_status = request.status - response_to_record = - if response && response != request.resolution_note, do: response + response_to_record = + if response && response != request.resolution_note, do: response - with {:ok, request} <- - request |> SupportRequest.moderation_changeset(attrs) |> Repo.update(), - {:ok, _event} <- - maybe_record_status_event( - request, - previous_status, - request.status, - moderator.id, - :staff - ), - {:ok, _message} <- - maybe_record_message(request, moderator.id, :staff, response_to_record), - {:ok, _audit} <- - Trust.audit( - moderator.id, - "support_request.moderated", - "support_request", - request.id, - %{ - "status" => to_string(request.status) - } - ) do - {:ok, preload_conversation(request)} + with {:ok, request} <- + request |> SupportRequest.moderation_changeset(attrs) |> Repo.update(), + {:ok, _event} <- + maybe_record_status_event( + request, + previous_status, + request.status, + moderator.id, + :staff + ), + {:ok, _message} <- + maybe_record_message(request, moderator.id, :staff, response_to_record), + {:ok, _audit} <- + Trust.audit( + moderator.id, + "support_request.moderated", + "support_request", + request.id, + %{ + "status" => to_string(request.status), + "assigned_to_id" => request.assigned_to_id + } + ) do + {:ok, preload_conversation(request)} + end + else + {:error, :not_found} end - else - {:error, :not_found} - end - end) - |> notify_decision() - |> broadcast_request_update() + end) + |> notify_decision() + |> broadcast_request_update() + end else false -> {:error, :forbidden} _ -> {:error, :not_found} @@ -343,7 +350,7 @@ defmodule WhoNeedHelp.Support do end def deletion_assessment(%Scope{user: moderator} = scope, id) do - with true <- Accounts.moderator_authorized?(moderator), + with true <- Accounts.authorized?(moderator, :support_manage), {:ok, id} <- Ecto.UUID.cast(id), %SupportRequest{} = request <- Repo.one( @@ -487,7 +494,7 @@ defmodule WhoNeedHelp.Support do defp preload_conversation(%SupportRequest{} = request) do Repo.preload( request, - [conversation_messages: :sender, status_events: :actor], + [:assigned_to, conversation_messages: :sender, status_events: :actor], force: true ) end @@ -565,8 +572,54 @@ defmodule WhoNeedHelp.Support do end defp maybe_kind(query, nil), do: query + defp maybe_kind(query, ""), do: query defp maybe_kind(query, kind), do: where(query, [request], request.kind == ^kind) + defp maybe_status(query, nil), do: query + defp maybe_status(query, ""), do: query + defp maybe_status(query, status), do: where(query, [request], request.status == ^status) + + defp maybe_assignee(query, nil, _current_user_id), do: query + defp maybe_assignee(query, "", _current_user_id), do: query + + defp maybe_assignee(query, "unassigned", _current_user_id), + do: where(query, [r], is_nil(r.assigned_to_id)) + + defp maybe_assignee(query, "mine", current_user_id), + do: where(query, [r], r.assigned_to_id == ^current_user_id) + + defp maybe_assignee(query, assignee_id, _current_user_id), + do: where(query, [r], r.assigned_to_id == ^assignee_id) + + defp maybe_search(query, value) when value in [nil, ""], do: query + + defp maybe_search(query, value) do + term = value |> String.trim() |> String.replace("%", "") |> String.replace("_", "") + pattern = "%#{term}%" + + where( + query, + [request], + ilike(request.reference, ^pattern) or ilike(request.contact_email, ^pattern) or + ilike(request.subject, ^pattern) + ) + end + + defp normalize_assignment(attrs, permission) do + case Map.fetch(attrs, "assigned_to_id") do + :error -> + {:ok, attrs} + + {:ok, value} when value in [nil, ""] -> + {:ok, Map.put(attrs, "assigned_to_id", nil)} + + {:ok, user_id} -> + if Accounts.authorized_user_id?(user_id, permission), + do: {:ok, attrs}, + else: {:error, :invalid_assignee} + end + end + defp rate_scopes(user, contact_email, client_scope) do [{:support_request, rate_scope(user, contact_email)}] |> maybe_add_client_rate_scope(client_scope) diff --git a/lib/who_need_help/support/support_request.ex b/lib/who_need_help/support/support_request.ex index 88b06ba..27dd718 100644 --- a/lib/who_need_help/support/support_request.ex +++ b/lib/who_need_help/support/support_request.ex @@ -41,6 +41,7 @@ defmodule WhoNeedHelp.Support.SupportRequest do field :response_sent_at, :utc_datetime belongs_to :requester, WhoNeedHelp.Accounts.User belongs_to :reviewed_by, WhoNeedHelp.Accounts.User + belongs_to :assigned_to, WhoNeedHelp.Accounts.User has_many :conversation_messages, WhoNeedHelp.Support.ConversationMessage, preload_order: [asc: :inserted_at, asc: :id] @@ -68,7 +69,14 @@ defmodule WhoNeedHelp.Support.SupportRequest do def moderation_changeset(request, attrs) do request - |> cast(attrs, [:status, :resolution_note, :reviewed_at, :reviewed_by_id, :response_sent_at]) + |> cast(attrs, [ + :status, + :resolution_note, + :reviewed_at, + :reviewed_by_id, + :response_sent_at, + :assigned_to_id + ]) |> validate_required([:status, :reviewed_at, :reviewed_by_id]) |> validate_length(:resolution_note, max: 5_000) end diff --git a/lib/who_need_help/trust.ex b/lib/who_need_help/trust.ex index 366df34..8cee4e0 100644 --- a/lib/who_need_help/trust.ex +++ b/lib/who_need_help/trust.ex @@ -332,7 +332,7 @@ defmodule WhoNeedHelp.Trust do end def paginate_reports(%Scope{user: user}, status \\ nil, options \\ []) do - if Accounts.moderator_authorized?(user) do + if Accounts.authorized?(user, :moderation_view) do limit = Pagination.limit(options) cursor = Pagination.cursor(options) @@ -359,7 +359,7 @@ defmodule WhoNeedHelp.Trust do def moderate_report(%Scope{user: moderator}, report_id, attrs) do with {:ok, report_id} <- cast_id(report_id), - true <- Accounts.moderator_authorized?(moderator) do + true <- Accounts.authorized?(moderator, :moderation_manage) do attrs = attrs |> stringify_keys() @@ -413,7 +413,7 @@ defmodule WhoNeedHelp.Trust do def report_evidence(%Scope{user: moderator}, report_id) do with {:ok, report_id} <- cast_id(report_id), - true <- Accounts.moderator_authorized?(moderator) do + true <- Accounts.authorized?(moderator, :moderation_evidence_view) do report = Report |> Repo.get(report_id) @@ -667,7 +667,7 @@ defmodule WhoNeedHelp.Trust do end def paginate_abuse_signals(%Scope{user: user}, status \\ :open, options \\ []) do - if Accounts.moderator_authorized?(user) do + if Accounts.authorized?(user, :moderation_view) do limit = Pagination.limit(options) cursor = Pagination.cursor(options) @@ -686,7 +686,7 @@ defmodule WhoNeedHelp.Trust do def moderate_signal(%Scope{user: moderator}, signal_id, attrs) do with {:ok, signal_id} <- cast_id(signal_id), - true <- Accounts.moderator_authorized?(moderator) do + true <- Accounts.authorized?(moderator, :moderation_manage) do attrs = attrs |> stringify_keys() @@ -723,7 +723,7 @@ defmodule WhoNeedHelp.Trust do def hide_request(%Scope{user: moderator}, request_id, reason) do result = with {:ok, request_id} <- cast_id(request_id), - true <- Accounts.moderator_authorized?(moderator) do + true <- Accounts.authorized?(moderator, :moderation_manage) do Repo.transact(fn -> request = HelpRequest @@ -763,7 +763,7 @@ defmodule WhoNeedHelp.Trust do def restore_request(%Scope{user: moderator}, request_id) do result = with {:ok, request_id} <- cast_id(request_id), - true <- Accounts.moderator_authorized?(moderator) do + true <- Accounts.authorized?(moderator, :moderation_manage) do Repo.transact(fn -> request = HelpRequest @@ -798,7 +798,7 @@ defmodule WhoNeedHelp.Trust do def hide_activity(%Scope{user: moderator}, activity_id, reason) do result = with {:ok, activity_id} <- cast_id(activity_id), - true <- Accounts.moderator_authorized?(moderator) do + true <- Accounts.authorized?(moderator, :moderation_manage) do Repo.transact(fn -> activity = Activity @@ -838,7 +838,7 @@ defmodule WhoNeedHelp.Trust do def restore_activity(%Scope{user: moderator}, activity_id) do result = with {:ok, activity_id} <- cast_id(activity_id), - true <- Accounts.moderator_authorized?(moderator) do + true <- Accounts.authorized?(moderator, :moderation_manage) do Repo.transact(fn -> activity = Activity @@ -910,16 +910,42 @@ defmodule WhoNeedHelp.Trust do end end - def moderate_role(%Scope{user: admin}, user_id, attrs) do - Repo.transact(fn -> - with {:ok, user} <- Accounts.change_user_role(admin, user_id, attrs), - {:ok, _audit} <- - audit(admin.id, "user.role_changed", "user", user.id, %{ - "role" => to_string(user.role) - }) do - {:ok, user} - end - end) + def set_staff_roles(%Scope{user: admin}, user_id, roles) do + result = + Repo.transact(fn -> + with {:ok, %{user: user} = change} <- Accounts.set_staff_roles(admin, user_id, roles), + {:ok, _audit} <- + audit(admin.id, "user.staff_roles_changed", "user", user.id, %{ + "roles" => Enum.map(Accounts.loaded_staff_roles(user), &to_string/1) + }) do + {:ok, change} + end + end) + + with {:ok, %{user: user, session_tokens: session_tokens}} <- result do + WhoNeedHelpWeb.UserAuth.disconnect_sessions(session_tokens) + {:ok, user} + end + end + + def paginate_audit_events(%Scope{user: user}, options \\ []) do + if Accounts.authorized?(user, :audit_view) do + limit = Pagination.limit(options) + cursor = Pagination.cursor(options) + + AuditEvent + |> maybe_audit_action(Keyword.get(options, :action)) + |> maybe_audit_actor(Keyword.get(options, :actor_id)) + |> maybe_audit_search(Keyword.get(options, :search)) + |> before_audit_event(cursor) + |> order_by([event], desc: event.inserted_at, desc: event.id) + |> limit(^(limit + 1)) + |> preload(:actor) + |> Repo.all() + |> Pagination.page(limit, &{&1.inserted_at, &1.id}) + else + %Pagination.Page{} + end end def audit(actor_id, action, target_type, target_id, metadata \\ %{}) do @@ -934,6 +960,43 @@ defmodule WhoNeedHelp.Trust do |> Repo.insert() end + defp maybe_audit_action(query, value) when value in [nil, ""], do: query + defp maybe_audit_action(query, value), do: where(query, [event], event.action == ^value) + + defp maybe_audit_actor(query, value) when value in [nil, ""], do: query + + defp maybe_audit_actor(query, value) do + case cast_id(value) do + {:ok, actor_id} -> where(query, [event], event.actor_id == ^actor_id) + _ -> where(query, [event], false) + end + end + + defp maybe_audit_search(query, value) when value in [nil, ""], do: query + + defp maybe_audit_search(query, value) do + term = value |> String.trim() |> String.replace("%", "") |> String.replace("_", "") + pattern = "%#{term}%" + + where( + query, + [event], + ilike(event.action, ^pattern) or ilike(event.target_type, ^pattern) or + fragment("CAST(? AS text) ILIKE ?", event.target_id, ^pattern) + ) + end + + defp before_audit_event(query, nil), do: query + + defp before_audit_event(query, {inserted_at, id}) do + where( + query, + [event], + event.inserted_at < ^inserted_at or + (event.inserted_at == ^inserted_at and event.id < ^id) + ) + end + defp users_by_id([]), do: %{} defp users_by_id(ids) do diff --git a/lib/who_need_help_web/components/admin_components.ex b/lib/who_need_help_web/components/admin_components.ex new file mode 100644 index 0000000..49add7e --- /dev/null +++ b/lib/who_need_help_web/components/admin_components.ex @@ -0,0 +1,139 @@ +defmodule WhoNeedHelpWeb.AdminComponents do + use WhoNeedHelpWeb, :html + + alias WhoNeedHelp.Accounts + + attr :current_scope, :map, required: true + attr :flash, :map, required: true + attr :active, :atom, required: true + attr :title, :string, required: true + attr :description, :string, default: nil + slot :inner_block, required: true + + def shell(assigns) do + ~H""" + +
+ + +
+
+
+ + <.icon name="hero-lock-closed" class="size-5" /> + +
+

+ {gettext("Restricted workspace")} +

+

{@title}

+

+ {@description} +

+
+
+
+ +
{render_slot(@inner_block)}
+
+
+
+ """ + end + + attr :active, :boolean, required: true + attr :href, :string, required: true + attr :icon, :string, required: true + slot :inner_block, required: true + + defp nav_item(assigns) do + ~H""" +
  • + <.link + navigate={@href} + aria-current={@active && "page"} + class={[ + "min-h-11 gap-3 rounded-xl font-semibold", + @active && "bg-primary text-primary-content" + ]} + > + <.icon name={@icon} class="size-5 shrink-0" /> + {render_slot(@inner_block)} + +
  • + """ + end + + defp support_nav_label(user) do + case { + Accounts.permission?(user, :support_view), + Accounts.permission?(user, :legal_view) + } do + {true, true} -> gettext("Support and legal") + {true, false} -> gettext("Support") + {false, true} -> gettext("Legal and removal") + end + end +end diff --git a/lib/who_need_help_web/components/layouts.ex b/lib/who_need_help_web/components/layouts.ex index 2f142eb..77950e4 100644 --- a/lib/who_need_help_web/components/layouts.ex +++ b/lib/who_need_help_web/components/layouts.ex @@ -142,16 +142,13 @@ defmodule WhoNeedHelpWeb.Layouts do <.link href={~p"/users/settings"}>{gettext("Account settings")} -
  • - <.link navigate={~p"/moderation"}>{gettext("Moderation")} -
  • -
  • - <.link navigate={~p"/support/operations"}>{gettext("Support queue")} +
  • + <.link navigate={~p"/admin"}>{gettext("Staff workspace")}
  • <.link href={~p"/users/log-out"} method="delete">{gettext("Log out")} @@ -387,16 +384,13 @@ defmodule WhoNeedHelpWeb.Layouts do <.link href={~p"/users/settings"}>{gettext("Account settings")}
  • -
  • - <.link navigate={~p"/moderation"}>{gettext("Moderation")} -
  • -
  • - <.link navigate={~p"/support/operations"}>{gettext("Support queue")} +
  • + <.link navigate={~p"/admin"}>{gettext("Staff workspace")}
  • <.link href={~p"/users/log-out"} method="delete">{gettext("Log out")} diff --git a/lib/who_need_help_web/live/admin_audit_live.ex b/lib/who_need_help_web/live/admin_audit_live.ex new file mode 100644 index 0000000..e19e645 --- /dev/null +++ b/lib/who_need_help_web/live/admin_audit_live.ex @@ -0,0 +1,186 @@ +defmodule WhoNeedHelpWeb.AdminAuditLive do + use WhoNeedHelpWeb, :live_view + + alias WhoNeedHelp.Trust + alias WhoNeedHelpWeb.AdminComponents + + @impl true + def mount(_params, _session, socket) do + filters = %{"search" => "", "action" => "", "actor_id" => ""} + + {:ok, + socket + |> assign(:page_title, gettext("Audit log")) + |> assign(:filters, filters) + |> assign(:filter_form, to_form(filters, as: :filters)) + |> load_events()} + end + + @impl true + def handle_event("filter", %{"filters" => filters}, socket) do + filters = Map.merge(socket.assigns.filters, filters) + + {:noreply, + socket + |> assign(:filters, filters) + |> assign(:filter_form, to_form(filters, as: :filters)) + |> load_events()} + end + + def handle_event("load-more", _params, socket) do + page = + Trust.paginate_audit_events( + socket.assigns.current_scope, + event_options(socket, socket.assigns.events_cursor) + ) + + existing_ids = MapSet.new(socket.assigns.events, & &1.id) + + {:noreply, + socket + |> assign( + :events, + socket.assigns.events ++ Enum.reject(page.entries, &MapSet.member?(existing_ids, &1.id)) + ) + |> assign(:events_cursor, page.next_cursor)} + end + + defp load_events(socket) do + page = Trust.paginate_audit_events(socket.assigns.current_scope, event_options(socket)) + socket |> assign(:events, page.entries) |> assign(:events_cursor, page.next_cursor) + end + + defp event_options(socket, cursor \\ nil) do + filters = socket.assigns.filters + + [ + search: filters["search"], + action: filters["action"], + actor_id: filters["actor_id"], + after: cursor + ] + end + + @impl true + def render(assigns) do + ~H""" + + <.form + for={@filter_form} + id="admin-audit-filters" + phx-change="filter" + class="grid gap-3 rounded-3xl border border-base-300 bg-base-100 p-4 md:grid-cols-3" + > + <.input + field={@filter_form[:search]} + type="search" + label={gettext("Target or action contains")} + phx-debounce="300" + /> + <.input + field={@filter_form[:action]} + label={gettext("Exact action")} + placeholder="user.staff_roles_changed" + phx-debounce="300" + /> + <.input field={@filter_form[:actor_id]} label={gettext("Actor UUID")} phx-debounce="300" /> + + +
    +

    + {gettext("No audit events match these filters.")} +

    +
    +
    + {event.action} + +
    +
    +
    +
    + {gettext("Actor")} +
    +
    + {(event.actor && (event.actor.display_name || event.actor.email)) || gettext("System")} +
    +
    +
    +
    + {gettext("Target")} +
    +
    + {event.target_type}: {event.target_id} +
    +
    +
    +
    + {gettext("Metadata")} +
    +
    +
    {Jason.encode!(event.metadata, pretty: true)}
    +
    +
    +
    +
    +
    + + + +
    + """ + end +end diff --git a/lib/who_need_help_web/live/admin_dashboard_live.ex b/lib/who_need_help_web/live/admin_dashboard_live.ex new file mode 100644 index 0000000..0f416c8 --- /dev/null +++ b/lib/who_need_help_web/live/admin_dashboard_live.ex @@ -0,0 +1,136 @@ +defmodule WhoNeedHelpWeb.AdminDashboardLive do + use WhoNeedHelpWeb, :live_view + + alias WhoNeedHelp.{Accounts, Admin} + alias WhoNeedHelpWeb.AdminComponents + + @impl true + def mount(_params, _session, socket) do + {:ok, + socket + |> assign(:page_title, gettext("Staff workspace")) + |> assign(:summary, Admin.dashboard(socket.assigns.current_scope))} + end + + @impl true + def render(assigns) do + ~H""" + +
    + <.metric_card + :if={Map.has_key?(@summary, :support_open)} + value={@summary[:support_open]} + label={gettext("Verified support cases")} + href={~p"/support/operations"} + icon="hero-chat-bubble-left-right" + /> + <.metric_card + :if={Map.has_key?(@summary, :legal_open)} + value={@summary[:legal_open]} + label={gettext("Legal and removal cases")} + href={~p"/support/operations"} + icon="hero-scale" + /> + <.metric_card + :if={Map.has_key?(@summary, :reports_open)} + value={@summary[:reports_open]} + label={gettext("Open reports")} + href={~p"/moderation"} + icon="hero-flag" + /> + <.metric_card + :if={Map.has_key?(@summary, :signals_open)} + value={@summary[:signals_open]} + label={gettext("Open trust signals")} + href={~p"/moderation"} + icon="hero-exclamation-triangle" + /> + <.metric_card + :if={Map.has_key?(@summary, :category_proposals_open)} + value={@summary[:category_proposals_open]} + label={gettext("Category proposals")} + href={~p"/moderation"} + icon="hero-tag" + /> + <.metric_card + :if={Map.has_key?(@summary, :users_total)} + value={@summary[:users_total]} + label={gettext("Registered users")} + href={~p"/admin/users"} + icon="hero-users" + /> + <.metric_card + :if={Map.has_key?(@summary, :staff_total)} + value={@summary[:staff_total]} + label={gettext("Staff accounts")} + href={~p"/admin/users"} + icon="hero-identification" + /> +
    + +
    +
    +
    +

    {gettext("Your staff access")}

    +

    + {gettext( + "An account may hold several roles. Permissions are the union of those roles; administrator includes every permission." + )} +

    +
    +
    + + {role_label(role)} + +
    +
    +
    +
    + """ + end + + attr :value, :integer, required: true + attr :label, :string, required: true + attr :href, :string, required: true + attr :icon, :string, required: true + + defp metric_card(assigns) do + ~H""" + <.link + navigate={@href} + class="group rounded-3xl border border-base-300 bg-base-100 p-5 transition hover:border-primary/40 hover:shadow-md" + > +
    + + <.icon name={@icon} class="size-5" /> + + <.icon + name="hero-arrow-up-right" + class="size-5 opacity-40 transition group-hover:opacity-100" + /> +
    +

    {@value}

    +

    {@label}

    + + """ + end + + defp role_label(:support), do: gettext("Support") + defp role_label(:moderator), do: gettext("Moderator") + defp role_label(:legal), do: gettext("Legal") + defp role_label(:analyst), do: gettext("Analyst") + defp role_label(:admin), do: gettext("Administrator") +end diff --git a/lib/who_need_help_web/live/admin_users_live.ex b/lib/who_need_help_web/live/admin_users_live.ex new file mode 100644 index 0000000..1efe468 --- /dev/null +++ b/lib/who_need_help_web/live/admin_users_live.ex @@ -0,0 +1,293 @@ +defmodule WhoNeedHelpWeb.AdminUsersLive do + use WhoNeedHelpWeb, :live_view + + alias WhoNeedHelp.{Accounts, Trust} + alias WhoNeedHelp.Accounts.StaffPermissions + alias WhoNeedHelpWeb.AdminComponents + + @impl true + def mount(_params, _session, socket) do + filters = %{"search" => "", "status" => "", "role" => ""} + + {:ok, + socket + |> assign(:page_title, gettext("Users and roles")) + |> assign(:filters, filters) + |> assign(:filter_form, to_form(filters, as: :filters)) + |> assign(:roles, StaffPermissions.roles()) + |> load_users()} + end + + @impl true + def handle_event("filter", %{"filters" => filters}, socket) do + filters = Map.merge(socket.assigns.filters, filters) + + {:noreply, + socket + |> assign(:filters, filters) + |> assign(:filter_form, to_form(filters, as: :filters)) + |> load_users()} + end + + def handle_event("load-more", _params, socket) do + page = + Accounts.paginate_users_for_moderation( + socket.assigns.current_scope, + user_options(socket, socket.assigns.users_cursor) + ) + + existing_ids = MapSet.new(socket.assigns.users, & &1.id) + + {:noreply, + socket + |> assign( + :users, + socket.assigns.users ++ Enum.reject(page.entries, &MapSet.member?(existing_ids, &1.id)) + ) + |> assign(:users_cursor, page.next_cursor)} + end + + def handle_event("moderate-user", %{"id" => id, "moderation" => params}, socket) do + respond( + socket, + Trust.moderate_user(socket.assigns.current_scope, id, params), + gettext("Account status updated.") + ) + end + + def handle_event("set-staff-roles", %{"id" => id, "staff" => params}, socket) do + roles = params |> Map.get("roles", []) |> List.wrap() |> Enum.reject(&(&1 == "")) + + respond( + socket, + Trust.set_staff_roles(socket.assigns.current_scope, id, roles), + gettext("Staff roles updated.") + ) + end + + defp respond(socket, {:ok, _record}, message), + do: {:noreply, socket |> put_flash(:info, message) |> load_users()} + + defp respond(socket, {:error, reason}, _message), + do: {:noreply, put_flash(socket, :error, error_message(reason))} + + defp load_users(socket) do + page = + Accounts.paginate_users_for_moderation(socket.assigns.current_scope, user_options(socket)) + + socket |> assign(:users, page.entries) |> assign(:users_cursor, page.next_cursor) + end + + defp user_options(socket, cursor \\ nil) do + filters = socket.assigns.filters + [search: filters["search"], status: filters["status"], role: filters["role"], after: cursor] + end + + defp error_message(:forbidden), + do: + gettext( + "You do not have permission for this action. Role changes also require a recent sign-in." + ) + + defp error_message(:cannot_restrict_self), + do: gettext("You cannot restrict or suspend your own account.") + + defp error_message(:last_admin), + do: + gettext("The last active administrator cannot lose administrator access or be restricted.") + + defp error_message(:invalid_roles), do: gettext("One or more staff roles are invalid.") + defp error_message(:not_found), do: gettext("The selected account is no longer available.") + defp error_message(%Ecto.Changeset{}), do: gettext("Check the status and internal note.") + defp error_message(_reason), do: gettext("The account could not be updated.") + + defp role_label(:support), do: gettext("Support") + defp role_label(:moderator), do: gettext("Moderator") + defp role_label(:legal), do: gettext("Legal") + defp role_label(:analyst), do: gettext("Analyst") + defp role_label(:admin), do: gettext("Administrator") + + defp role_description(:support), do: gettext("Support conversations and verified support cases") + + defp role_description(:moderator), + do: gettext("Reports, safety signals, user restrictions, and categories") + + defp role_description(:legal), do: gettext("Content-removal and legal notice queue") + defp role_description(:analyst), do: gettext("Privacy-preserving aggregate product analytics") + defp role_description(:admin), do: gettext("All permissions, staff roles, and audit log") + + defp target_has_staff_roles?(user), do: Accounts.loaded_staff_roles(user) != [] + + defp can_moderate?(actor, target) do + Accounts.permission?(actor, :users_moderate) and + (not target_has_staff_roles?(target) or Accounts.permission?(actor, :staff_manage)) + end + + @impl true + def render(assigns) do + ~H""" + + <.form + for={@filter_form} + id="admin-user-filters" + phx-change="filter" + class="grid gap-3 rounded-3xl border border-base-300 bg-base-100 p-4 md:grid-cols-[minmax(0,1fr)_12rem_12rem]" + > + <.input + field={@filter_form[:search]} + type="search" + label={gettext("Search users")} + placeholder={gettext("Email or display name")} + phx-debounce="300" + /> + <.input + field={@filter_form[:status]} + type="select" + label={gettext("Account status")} + options={[ + {gettext("Any status"), ""}, + {gettext("Active"), "active"}, + {gettext("Restricted"), "restricted"}, + {gettext("Suspended"), "suspended"} + ]} + /> + <.input + field={@filter_form[:role]} + type="select" + label={gettext("Staff role")} + options={ + [{gettext("Any role"), ""}, {gettext("No staff role"), "user"}] ++ + Enum.map(@roles, &{role_label(&1), Atom.to_string(&1)}) + } + /> + + +
    +
    + <% status_form = + to_form( + %{ + "moderation_status" => to_string(user.moderation_status), + "moderation_note" => user.moderation_note || "" + }, + as: :moderation, + id: "status-#{user.id}" + ) %> +
    +
    +
    +

    {user.display_name}

    + {user.moderation_status} +
    +

    {user.email}

    +
    + {gettext( + "Regular user" + )} + {role_label(role)} +
    +
    + + <.form + :if={can_moderate?(@current_scope.user, user)} + for={status_form} + id={"moderate-user-#{user.id}"} + phx-submit="moderate-user" + phx-value-id={user.id} + class="grid min-w-0 gap-3 sm:grid-cols-[11rem_minmax(14rem,1fr)_auto] xl:w-[42rem]" + > + <.input + field={status_form[:moderation_status]} + type="select" + label={gettext("Status")} + options={[ + {gettext("Active"), "active"}, + {gettext("Restricted"), "restricted"}, + {gettext("Suspended"), "suspended"} + ]} + /> + <.input + field={status_form[:moderation_note]} + label={gettext("Internal note")} + maxlength="1000" + /> + + +
    + + <.form + :if={Accounts.permission?(@current_scope.user, :staff_manage)} + for={to_form(%{}, as: :staff, id: "roles-#{user.id}")} + id={"staff-roles-#{user.id}"} + phx-submit="set-staff-roles" + phx-value-id={user.id} + class="mt-5 border-t border-base-300 pt-5" + > +
    +
    + {gettext("Staff roles")} + + +
    + +
    + +
    + +

    + {gettext("No users match these filters.")} +

    +
    + + +
    + """ + end +end diff --git a/lib/who_need_help_web/live/moderation_live.ex b/lib/who_need_help_web/live/moderation_live.ex index 53dfd16..6a0417e 100644 --- a/lib/who_need_help_web/live/moderation_live.ex +++ b/lib/who_need_help_web/live/moderation_live.ex @@ -1,7 +1,8 @@ defmodule WhoNeedHelpWeb.ModerationLive do use WhoNeedHelpWeb, :live_view - alias WhoNeedHelp.{Accounts, Catalog, Trust} + alias WhoNeedHelp.{Catalog, Trust} + alias WhoNeedHelpWeb.AdminComponents @impl true def mount(_params, _session, socket) do @@ -35,22 +36,6 @@ defmodule WhoNeedHelpWeb.ModerationLive do ) end - def handle_event("moderate-user", %{"id" => id, "moderation" => params}, socket) do - respond( - socket, - Trust.moderate_user(socket.assigns.current_scope, id, params), - gettext("User status updated.") - ) - end - - def handle_event("moderate-role", %{"id" => id, "moderation" => params}, socket) do - respond( - socket, - Trust.moderate_role(socket.assigns.current_scope, id, params), - gettext("User role updated.") - ) - end - def handle_event("hide-request", %{"id" => id, "moderation" => %{"note" => note}}, socket) do respond( socket, @@ -173,15 +158,6 @@ defmodule WhoNeedHelpWeb.ModerationLive do {:noreply, append_page(socket, :proposals, page)} end - def handle_event("load-more-users", _params, socket) do - page = - Accounts.paginate_users_for_moderation(socket.assigns.current_scope, - after: socket.assigns.users_cursor - ) - - {:noreply, append_page(socket, :users, page)} - end - defp respond(socket, {:ok, _value}, message) do {:noreply, socket |> put_flash(:info, message) |> load()} end @@ -198,7 +174,6 @@ defmodule WhoNeedHelpWeb.ModerationLive do reports = Trust.paginate_reports(socket.assigns.current_scope) signals = Trust.paginate_abuse_signals(socket.assigns.current_scope) proposals = Catalog.paginate_proposals_for_moderation(socket.assigns.current_scope) - users = Accounts.paginate_users_for_moderation(socket.assigns.current_scope) socket |> assign(:reports, reports.entries) @@ -207,8 +182,6 @@ defmodule WhoNeedHelpWeb.ModerationLive do |> assign(:signals_cursor, signals.next_cursor) |> assign(:proposals, proposals.entries) |> assign(:proposals_cursor, proposals.next_cursor) - |> assign(:users, users.entries) - |> assign(:users_cursor, users.next_cursor) |> assign(:categories, Catalog.list_all_categories()) end @@ -225,7 +198,6 @@ defmodule WhoNeedHelpWeb.ModerationLive do defp cursor_assign(:reports), do: :reports_cursor defp cursor_assign(:signals), do: :signals_cursor defp cursor_assign(:proposals), do: :proposals_cursor - defp cursor_assign(:users), do: :users_cursor defp error_message(:forbidden), do: gettext("Moderator access is required.") defp error_message(:proposal_closed), do: gettext("This proposal has already been reviewed.") @@ -268,11 +240,6 @@ defmodule WhoNeedHelpWeb.ModerationLive do defp status_label(:suspended), do: gettext("Suspended") defp status_label(value), do: to_string(value) - defp role_label(:user), do: gettext("User") - defp role_label(:moderator), do: gettext("Moderator") - defp role_label(:admin), do: gettext("Administrator") - defp role_label(value), do: to_string(value) - defp signal_label(:velocity), do: gettext("Unusual action rate") defp signal_label(:repeated_pair), do: gettext("Repeated participant pair") @@ -316,30 +283,20 @@ defmodule WhoNeedHelpWeb.ModerationLive do defp report_activity(_report), do: nil - defp can_moderate_account?(%{role: :admin}, _target), do: true - defp can_moderate_account?(%{role: :moderator}, %{role: :user}), do: true - defp can_moderate_account?(_actor, _target), do: false - @impl true def render(assigns) do ~H""" - -
    {gettext("RESTRICTED WORKSPACE")}
    -
    -

    {gettext("Moderation")}

    -
    - <.link navigate={~p"/analytics"} class="btn btn-outline btn-sm"> - {gettext("Product analytics")} - - <.link navigate={~p"/support/operations"} class="btn btn-outline btn-sm"> - {gettext("Support operations")} - -
    -
    -

    - {gettext("Decisions and access to reported chat evidence are written to the audit log.")} -

    - +

    {gettext("Reports")}

    @@ -651,106 +608,7 @@ defmodule WhoNeedHelpWeb.ModerationLive do {gettext("Load more")}
    - -
    -

    {gettext("Accounts")}

    -
    - - - - - - - - - - - - <% user_form = - scoped_form( - %{ - "moderation_status" => to_string(user.moderation_status), - "moderation_note" => user.moderation_note || "" - }, - :moderation, - "user-status-#{user.id}" - ) %> - <% role_form = - scoped_form( - %{"role" => to_string(user.role)}, - :moderation, - "user-role-#{user.id}" - ) %> - - - - - - -
    {gettext("User")}{gettext("Role")}{gettext("Status")}{gettext("Decision")}
    - {user.display_name} -
    {user.email}
    -
    {role_label(user.role)}{status_label(user.moderation_status)} - <.form - :if={can_moderate_account?(@current_scope.user, user)} - for={user_form} - phx-submit="moderate-user" - phx-value-id={user.id} - class="flex min-w-[28rem] gap-2" - > - <.input - field={user_form[:moderation_status]} - type="select" - options={[ - {gettext("Active"), "active"}, - {gettext("Restricted"), "restricted"}, - {gettext("Suspended"), "suspended"} - ]} - /> - <.input - field={user_form[:moderation_note]} - placeholder={gettext("Internal note")} - /> - <.button class="btn btn-sm btn-primary self-end">{gettext("Save")} - -

    - {gettext("Administrator access is required to moderate staff accounts.")} -

    - <.form - :if={@current_scope.user.role == :admin} - for={role_form} - phx-submit="moderate-role" - phx-value-id={user.id} - class="mt-2 flex gap-2" - > - <.input - field={role_form[:role]} - type="select" - options={[ - {gettext("User"), "user"}, - {gettext("Moderator"), "moderator"}, - {gettext("Administrator"), "admin"} - ]} - /> - <.button class="btn btn-sm btn-warning self-end"> - {gettext("Change role")} - - -
    -
    - -
    -
    + """ end end diff --git a/lib/who_need_help_web/live/product_analytics_live.ex b/lib/who_need_help_web/live/product_analytics_live.ex index 647ce0c..b6786dc 100644 --- a/lib/who_need_help_web/live/product_analytics_live.ex +++ b/lib/who_need_help_web/live/product_analytics_live.ex @@ -2,6 +2,7 @@ defmodule WhoNeedHelpWeb.ProductAnalyticsLive do use WhoNeedHelpWeb, :live_view alias WhoNeedHelp.ProductAnalytics + alias WhoNeedHelpWeb.AdminComponents @impl true def mount(_params, _session, socket) do @@ -35,23 +36,18 @@ defmodule WhoNeedHelpWeb.ProductAnalyticsLive do @impl true def render(assigns) do ~H""" - -
    {gettext("RESTRICTED WORKSPACE")}
    -
    -
    -

    {gettext("Product analytics")}

    -

    - {gettext( - "Daily aggregate counters only. No user ID, email, coordinate, request text, chat text, medicine name, or device credential is stored here." - )} -

    -
    - <.link navigate={~p"/moderation"} class="btn btn-outline btn-sm"> - {gettext("Back to moderation")} - -
    - -
    + +
    @@ -81,11 +77,11 @@ defmodule WhoNeedHelpWeb.ProductAnalyticsLive do :if={@metrics_cursor} type="button" phx-click="load-more" - class="btn btn-outline btn-sm mt-4" + class="btn btn-outline btn-sm" > {gettext("Load more")} - + """ end end diff --git a/lib/who_need_help_web/live/support_operations_live.ex b/lib/who_need_help_web/live/support_operations_live.ex index 2ad3e6f..a25c767 100644 --- a/lib/who_need_help_web/live/support_operations_live.ex +++ b/lib/who_need_help_web/live/support_operations_live.ex @@ -1,16 +1,33 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do use WhoNeedHelpWeb, :live_view - alias WhoNeedHelp.{ContentRemoval, Support} + alias WhoNeedHelp.{Accounts, ContentRemoval, Support} + alias WhoNeedHelpWeb.AdminComponents @impl true def mount(_params, _session, socket) do - if connected?(socket), do: Support.subscribe_staff() + support_allowed = Accounts.permission?(socket.assigns.current_scope.user, :support_view) + legal_allowed = Accounts.permission?(socket.assigns.current_scope.user, :legal_view) + + if connected?(socket) and support_allowed, do: Support.subscribe_staff() + + support_filters = %{"search" => "", "status" => "", "kind" => "", "assigned_to_id" => ""} + legal_filters = %{"search" => "", "status" => "", "regime" => "", "assigned_to_id" => ""} {:ok, socket |> assign(:page_title, gettext("Support operations")) + |> assign(:operations_title, operations_title(support_allowed, legal_allowed)) + |> assign(:operations_description, operations_description(support_allowed, legal_allowed)) |> assign(:deletion_assessments, %{}) + |> assign(:support_allowed, support_allowed) + |> assign(:legal_allowed, legal_allowed) + |> assign(:support_filters, support_filters) + |> assign(:legal_filters, legal_filters) + |> assign(:support_filter_form, to_form(support_filters, as: :support_filters)) + |> assign(:legal_filter_form, to_form(legal_filters, as: :legal_filters)) + |> assign(:support_assignees, staff_assignees(support_allowed, :support_manage)) + |> assign(:legal_assignees, staff_assignees(legal_allowed, :legal_manage)) |> load()} end @@ -36,6 +53,26 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do ) end + def handle_event("filter-support", %{"support_filters" => filters}, socket) do + filters = Map.merge(socket.assigns.support_filters, filters) + + {:noreply, + socket + |> assign(:support_filters, filters) + |> assign(:support_filter_form, to_form(filters, as: :support_filters)) + |> load_support()} + end + + def handle_event("filter-legal", %{"legal_filters" => filters}, socket) do + filters = Map.merge(socket.assigns.legal_filters, filters) + + {:noreply, + socket + |> assign(:legal_filters, filters) + |> assign(:legal_filter_form, to_form(filters, as: :legal_filters)) + |> load_removals()} + end + def handle_event("assess-deletion", %{"id" => id}, socket) do case Support.deletion_assessment(socket.assigns.current_scope, id) do {:ok, assessment} -> @@ -48,8 +85,9 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do def handle_event("load-more-support", _params, socket) do page = - Support.paginate_for_staff(socket.assigns.current_scope, - after: socket.assigns.support_cursor + Support.paginate_for_staff( + socket.assigns.current_scope, + support_options(socket, socket.assigns.support_cursor) ) {:noreply, append_page(socket, :support_requests, :support_cursor, page)} @@ -57,8 +95,9 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do def handle_event("load-more-removals", _params, socket) do page = - ContentRemoval.paginate_for_staff(socket.assigns.current_scope, - after: socket.assigns.removal_cursor + ContentRemoval.paginate_for_staff( + socket.assigns.current_scope, + legal_options(socket, socket.assigns.removal_cursor) ) {:noreply, append_page(socket, :removal_notices, :removal_cursor, page)} @@ -73,14 +112,69 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do end defp load(socket) do - support = Support.paginate_for_staff(socket.assigns.current_scope) - removals = ContentRemoval.paginate_for_staff(socket.assigns.current_scope) - socket - |> assign(:support_requests, support.entries) - |> assign(:support_cursor, support.next_cursor) - |> assign(:removal_notices, removals.entries) - |> assign(:removal_cursor, removals.next_cursor) + |> load_support() + |> load_removals() + end + + defp load_support(%{assigns: %{support_allowed: false}} = socket) do + socket |> assign(:support_requests, []) |> assign(:support_cursor, nil) + end + + defp load_support(socket) do + page = Support.paginate_for_staff(socket.assigns.current_scope, support_options(socket)) + socket |> assign(:support_requests, page.entries) |> assign(:support_cursor, page.next_cursor) + end + + defp load_removals(%{assigns: %{legal_allowed: false}} = socket) do + socket |> assign(:removal_notices, []) |> assign(:removal_cursor, nil) + end + + defp load_removals(socket) do + page = ContentRemoval.paginate_for_staff(socket.assigns.current_scope, legal_options(socket)) + socket |> assign(:removal_notices, page.entries) |> assign(:removal_cursor, page.next_cursor) + end + + defp support_options(socket, cursor \\ nil) do + filters = socket.assigns.support_filters + + [ + search: filters["search"], + status: filters["status"], + kind: filters["kind"], + assigned_to_id: filters["assigned_to_id"], + after: cursor + ] + end + + defp legal_options(socket, cursor \\ nil) do + filters = socket.assigns.legal_filters + + [ + search: filters["search"], + status: filters["status"], + regime: filters["regime"], + assigned_to_id: filters["assigned_to_id"], + after: cursor + ] + end + + defp staff_assignees(false, _permission), do: [] + defp staff_assignees(true, permission), do: Accounts.list_staff_for_permission(permission) + + defp assignee_options(users) do + [ + {gettext("Any assignee"), ""}, + {gettext("Unassigned"), "unassigned"}, + {gettext("Assigned to me"), "mine"} + | Enum.map(users, fn user -> + {user.display_name || user.email, user.id} + end) + ] + end + + defp assignment_options(users) do + [{gettext("Unassigned"), ""} | Enum.map(users, &{&1.display_name || &1.email, &1.id})] end defp append_page(socket, entries_key, cursor_key, page) do @@ -97,6 +191,10 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do defp error_message(:account_not_linked), do: gettext("The request is not linked to an account.") defp error_message(:contact_not_verified), do: gettext("Verify the requester contact first.") defp error_message(:account_not_found), do: gettext("The linked account no longer exists.") + + defp error_message(:invalid_assignee), + do: gettext("The selected assignee cannot manage this queue.") + defp error_message(%Ecto.Changeset{}), do: gettext("Please check the submitted fields.") defp error_message(_reason), do: gettext("Could not update the request. Please try again.") @@ -129,27 +227,86 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do defp scoped_form(data, id), do: to_form(data, as: :moderation, id: id) + defp operations_title(true, true), do: gettext("Support and legal operations") + defp operations_title(true, false), do: gettext("Support operations") + defp operations_title(false, true), do: gettext("Legal and content removal operations") + + defp operations_description(true, true) do + gettext( + "Verified support conversations and legal removal notices stay in separate permission-scoped queues. Assignments, decisions, and operator identity are audited." + ) + end + + defp operations_description(true, false) do + gettext( + "Verified support conversations stay in a permission-scoped queue. Assignments, replies, decisions, and operator identity are audited." + ) + end + + defp operations_description(false, true) do + gettext( + "Verified legal and content-removal notices stay in a permission-scoped queue. Assignments, decisions, and operator identity are audited." + ) + end + @impl true def render(assigns) do ~H""" - -
    {gettext("RESTRICTED WORKSPACE")}
    -
    -
    -

    {gettext("Support operations")}

    -

    - {gettext( - "Support requests and legal removal notices are separate queues. Decisions and operator identity are audited." - )} -

    -
    - <.link navigate={~p"/moderation"} class="btn btn-outline btn-sm"> - {gettext("Trust and safety moderation")} - -
    - -
    + +

    {gettext("Content removal and TAKE IT DOWN")}

    + <.form + for={@legal_filter_form} + id="legal-case-filters" + phx-change="filter-legal" + class="mt-4 grid gap-3 rounded-3xl border border-base-300 bg-base-100 p-4 md:grid-cols-2 xl:grid-cols-4" + > + <.input + field={@legal_filter_form[:search]} + type="search" + label={gettext("Search legal cases")} + placeholder={gettext("Reference, email, URL, or explanation")} + phx-debounce="300" + /> + <.input + field={@legal_filter_form[:status]} + type="select" + label={gettext("Status")} + options={[ + {gettext("Any status"), ""}, + {gettext("Open"), "open"}, + {gettext("Urgent review"), "urgent_review"}, + {gettext("Reviewing"), "reviewing"}, + {gettext("More information needed"), "needs_information"}, + {gettext("Action taken"), "actioned"}, + {gettext("Rejected"), "rejected"}, + {gettext("Closed"), "closed"} + ]} + /> + <.input + field={@legal_filter_form[:regime]} + type="select" + label={gettext("Regime")} + options={[ + {gettext("Any regime"), ""}, + {gettext("General"), "general"}, + {gettext("DSA"), "dsa"}, + {gettext("TAKE IT DOWN"), "take_it_down"} + ]} + /> + <.input + field={@legal_filter_form[:assigned_to_id]} + type="select" + label={gettext("Assignee")} + options={assignee_options(@legal_assignees)} + /> +
    to_string(notice.status), + "assigned_to_id" => notice.assigned_to_id || "", "resolution_note" => notice.resolution_note || "" }, "removal-#{notice.id}" @@ -178,6 +336,14 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do {gettext("review by %{time}", time: notice.response_due_at)} + + {if notice.assigned_to, + do: + gettext("Assigned: %{name}", + name: notice.assigned_to.display_name || notice.assigned_to.email + ), + else: gettext("Unassigned")} +
    {gettext("Submitter")}
    @@ -226,7 +392,7 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do {gettext("Audited update")} -
    +
    <.input field={form[:status]} @@ -247,6 +413,14 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do {gettext("Choose the state that will appear in the case history.")}

    +
    + <.input + field={form[:assigned_to_id]} + type="select" + label={gettext("Assigned operator")} + options={assignment_options(@legal_assignees)} + /> +
    <.input field={form[:resolution_note]} @@ -307,14 +481,64 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do
    -
    +

    {gettext("General support and data requests")}

    + <.form + for={@support_filter_form} + id="support-case-filters" + phx-change="filter-support" + class="mt-4 grid gap-3 rounded-3xl border border-base-300 bg-base-100 p-4 md:grid-cols-2 xl:grid-cols-4" + > + <.input + field={@support_filter_form[:search]} + type="search" + label={gettext("Search support cases")} + placeholder={gettext("Reference, email, subject, or details")} + phx-debounce="300" + /> + <.input + field={@support_filter_form[:status]} + type="select" + label={gettext("Status")} + options={[ + {gettext("Any status"), ""}, + {gettext("Open"), "open"}, + {gettext("Reviewing"), "reviewing"}, + {gettext("Waiting for requester"), "waiting_for_requester"}, + {gettext("Resolved"), "resolved"}, + {gettext("Closed"), "closed"} + ]} + /> + <.input + field={@support_filter_form[:kind]} + type="select" + label={gettext("Request type")} + options={[ + {gettext("Any type"), ""}, + {gettext("Account access"), "account_access"}, + {gettext("Technical issue"), "technical_issue"}, + {gettext("Safety concern"), "safety_concern"}, + {gettext("Moderation appeal"), "moderation_appeal"}, + {gettext("Account deletion"), "account_deletion"}, + {gettext("Data export"), "data_export"}, + {gettext("Privacy request"), "privacy_request"}, + {gettext("Other"), "other"} + ]} + /> + <.input + field={@support_filter_form[:assigned_to_id]} + type="select" + label={gettext("Assignee")} + options={assignee_options(@support_assignees)} + /> +
    <% form = scoped_form( %{ "status" => to_string(request.status), + "assigned_to_id" => request.assigned_to_id || "", "response" => "" }, "support-#{request.id}" @@ -326,6 +550,14 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do {gettext("contact unverified")} {support_kind(request.kind)} + + {if request.assigned_to, + do: + gettext("Assigned: %{name}", + name: request.assigned_to.display_name || request.assigned_to.email + ), + else: gettext("Unassigned")} +

    {request.subject}

    {request.contact_email}

    @@ -427,7 +659,7 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do {gettext("Audited update")} -
    +
    <.input field={form[:status]} @@ -446,6 +678,14 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do {gettext("Choose the state that will appear in the case history.")}

    +
    + <.input + field={form[:assigned_to_id]} + type="select" + label={gettext("Assigned operator")} + options={assignment_options(@support_assignees)} + /> +
    <.input field={form[:response]} @@ -505,7 +745,7 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do {gettext("Load more")}
    - + """ end end diff --git a/lib/who_need_help_web/router.ex b/lib/who_need_help_web/router.ex index 3301a53..4d4c2ff 100644 --- a/lib/who_need_help_web/router.ex +++ b/lib/who_need_help_web/router.ex @@ -178,10 +178,35 @@ defmodule WhoNeedHelpWeb.Router do live "/reports", ReportLive, :index end + live_session :staff_dashboard, + on_mount: [{WhoNeedHelpWeb.UserAuth, {:ensure_permission, :dashboard_view}}] do + live "/admin", AdminDashboardLive, :index + end + + live_session :staff_users, + on_mount: [{WhoNeedHelpWeb.UserAuth, {:ensure_permission, :users_view}}] do + live "/admin/users", AdminUsersLive, :index + end + + live_session :staff_audit, + on_mount: [{WhoNeedHelpWeb.UserAuth, {:ensure_permission, :audit_view}}] do + live "/admin/audit", AdminAuditLive, :index + end + live_session :moderation, - on_mount: [{WhoNeedHelpWeb.UserAuth, :ensure_moderator}] do + on_mount: [{WhoNeedHelpWeb.UserAuth, {:ensure_permission, :moderation_view}}] do live "/moderation", ModerationLive, :index + end + + live_session :analytics, + on_mount: [{WhoNeedHelpWeb.UserAuth, {:ensure_permission, :analytics_view}}] do live "/analytics", ProductAnalyticsLive, :index + end + + live_session :support_operations, + on_mount: [ + {WhoNeedHelpWeb.UserAuth, {:ensure_any_permission, [:support_view, :legal_view]}} + ] do live "/support/operations", SupportOperationsLive, :index end end diff --git a/lib/who_need_help_web/user_auth.ex b/lib/who_need_help_web/user_auth.ex index 76ca014..93f007d 100644 --- a/lib/who_need_help_web/user_auth.ex +++ b/lib/who_need_help_web/user_auth.ex @@ -294,16 +294,57 @@ defmodule WhoNeedHelpWeb.UserAuth do end def on_mount(:ensure_moderator, _params, session, socket) do + on_mount({:ensure_permission, :moderation_view}, %{}, session, socket) + end + + def on_mount({:ensure_permission, permission}, _params, session, socket) do case live_scope(session) do %Scope{user: %Accounts.User{} = user} = scope -> - if Accounts.moderator_authorized?(user) do + if Accounts.authorized?(user, permission) do set_live_locale(scope, session) {:cont, Phoenix.Component.assign(socket, :current_scope, scope)} else socket = socket |> Phoenix.Component.assign(:current_scope, scope) - |> Phoenix.LiveView.put_flash(:error, gettext("Moderator access is required.")) + |> Phoenix.LiveView.put_flash( + :error, + gettext("You do not have access to that workspace.") + ) + |> Phoenix.LiveView.redirect(to: ~p"/requests") + + {:halt, socket} + end + + _ -> + socket = + socket + |> Phoenix.Component.assign(:current_scope, nil) + |> Phoenix.LiveView.put_flash( + :error, + gettext("You must log in to access this page.") + ) + |> Phoenix.LiveView.redirect(to: ~p"/users/log-in") + + {:halt, socket} + end + end + + def on_mount({:ensure_any_permission, permissions}, _params, session, socket) + when is_list(permissions) do + case live_scope(session) do + %Scope{user: %Accounts.User{} = user} = scope -> + if Enum.any?(permissions, &Accounts.authorized?(user, &1)) do + set_live_locale(scope, session) + {:cont, Phoenix.Component.assign(socket, :current_scope, scope)} + else + socket = + socket + |> Phoenix.Component.assign(:current_scope, scope) + |> Phoenix.LiveView.put_flash( + :error, + gettext("You do not have access to that workspace.") + ) |> Phoenix.LiveView.redirect(to: ~p"/requests") {:halt, socket} diff --git a/priv/repo/migrations/20260801002933_add_staff_rbac_and_case_assignments.exs b/priv/repo/migrations/20260801002933_add_staff_rbac_and_case_assignments.exs new file mode 100644 index 0000000..c245044 --- /dev/null +++ b/priv/repo/migrations/20260801002933_add_staff_rbac_and_case_assignments.exs @@ -0,0 +1,93 @@ +defmodule WhoNeedHelp.Repo.Migrations.AddStaffRbacAndCaseAssignments do + use Ecto.Migration + + def up do + create table(:staff_role_assignments, primary_key: false) do + add :id, :binary_id, primary_key: true + add :role, :string, null: false + + add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false + + add :assigned_by_id, references(:users, type: :binary_id, on_delete: :nilify_all) + + timestamps(type: :utc_datetime) + end + + create unique_index(:staff_role_assignments, [:user_id, :role]) + create index(:staff_role_assignments, [:role, :user_id]) + create index(:staff_role_assignments, [:assigned_by_id]) + create index(:audit_events, [:inserted_at, :id]) + create index(:audit_events, [:actor_id, :inserted_at, :id]) + create index(:audit_events, [:action, :inserted_at, :id]) + + create constraint(:staff_role_assignments, :staff_role_assignments_known_role, + check: "role IN ('support', 'moderator', 'legal', 'analyst', 'admin')" + ) + + execute """ + INSERT INTO staff_role_assignments (id, user_id, role, inserted_at, updated_at) + SELECT md5(users.id::text || ':' || users.role)::uuid, + users.id, + users.role, + timezone('UTC', now()), + timezone('UTC', now()) + FROM users + WHERE users.role IN ('moderator', 'admin') + """ + + alter table(:support_requests) do + add :assigned_to_id, references(:users, type: :binary_id, on_delete: :nilify_all) + end + + create index(:support_requests, [:assigned_to_id, :status, :inserted_at, :id]) + + alter table(:content_removal_notices) do + add :assigned_to_id, references(:users, type: :binary_id, on_delete: :nilify_all) + end + + create index(:content_removal_notices, [:assigned_to_id, :status, :inserted_at, :id]) + + alter table(:users) do + remove :role + end + end + + def down do + drop index(:audit_events, [:action, :inserted_at, :id]) + drop index(:audit_events, [:actor_id, :inserted_at, :id]) + drop index(:audit_events, [:inserted_at, :id]) + + alter table(:users) do + add :role, :string, null: false, default: "user" + end + + execute """ + UPDATE users + SET role = CASE + WHEN EXISTS ( + SELECT 1 FROM staff_role_assignments assignment + WHERE assignment.user_id = users.id AND assignment.role = 'admin' + ) THEN 'admin' + WHEN EXISTS ( + SELECT 1 FROM staff_role_assignments assignment + WHERE assignment.user_id = users.id + ) THEN 'moderator' + ELSE 'user' + END + """ + + drop index(:content_removal_notices, [:assigned_to_id, :status, :inserted_at, :id]) + + alter table(:content_removal_notices) do + remove :assigned_to_id + end + + drop index(:support_requests, [:assigned_to_id, :status, :inserted_at, :id]) + + alter table(:support_requests) do + remove :assigned_to_id + end + + drop table(:staff_role_assignments) + end +end diff --git a/test/support/fixtures/accounts_fixtures.ex b/test/support/fixtures/accounts_fixtures.ex index f87a965..2000d02 100644 --- a/test/support/fixtures/accounts_fixtures.ex +++ b/test/support/fixtures/accounts_fixtures.ex @@ -7,7 +7,9 @@ defmodule WhoNeedHelp.AccountsFixtures do import Ecto.Query alias WhoNeedHelp.Accounts + alias WhoNeedHelp.Accounts.StaffRoleAssignment alias WhoNeedHelp.Accounts.Scope + alias WhoNeedHelp.Repo def unique_user_email, do: "user#{System.unique_integer()}@example.com" def valid_user_password, do: "hello world!" @@ -43,6 +45,28 @@ defmodule WhoNeedHelp.AccountsFixtures do user end + def staff_user_fixture(roles, attrs \\ %{}) when is_list(roles) do + user = %{user_fixture(attrs) | authenticated_at: DateTime.utc_now(:second)} + + Enum.each(roles, fn role -> + %StaffRoleAssignment{} + |> StaffRoleAssignment.changeset(%{user_id: user.id, role: role}) + |> Repo.insert!() + end) + + Accounts.preload_staff_roles(user) + end + + def grant_staff_roles(user, roles) when is_list(roles) do + Enum.each(roles, fn role -> + %StaffRoleAssignment{} + |> StaffRoleAssignment.changeset(%{user_id: user.id, role: role}) + |> Repo.insert!(on_conflict: :nothing, conflict_target: [:user_id, :role]) + end) + + Accounts.preload_staff_roles(user) + end + def user_scope_fixture do user = user_fixture() user_scope_fixture(user) diff --git a/test/who_need_help/accounts_test.exs b/test/who_need_help/accounts_test.exs index a63b3c0..7d0c41b 100644 --- a/test/who_need_help/accounts_test.exs +++ b/test/who_need_help/accounts_test.exs @@ -11,7 +11,7 @@ defmodule WhoNeedHelp.AccountsTest do alias WhoNeedHelp.Accounts import WhoNeedHelp.AccountsFixtures - alias WhoNeedHelp.Accounts.{SocialIdentity, User, UserToken} + alias WhoNeedHelp.Accounts.{SocialIdentity, StaffPermissions, User, UserToken} describe "get_user_by_email/1" do test "does not return the user if the email does not exist" do @@ -103,11 +103,12 @@ defmodule WhoNeedHelp.AccountsTest do |> set_password() |> Ecto.Changeset.change( bio: "A short public bio", - role: :admin, moderation_note: "private moderator note" ) |> Repo.update!() + _user = grant_staff_roles(user, [:admin]) + {:ok, identity} = Accounts.add_social_identity(user, %{ "provider" => "telegram", @@ -125,7 +126,7 @@ defmodule WhoNeedHelp.AccountsTest do assert identity_id == identity.id assert is_nil(public_user.email) assert is_nil(public_user.hashed_password) - assert public_user.role == :user + assert %Ecto.Association.NotLoaded{} = public_user.staff_role_assignments assert is_nil(public_user.moderation_note) end @@ -204,6 +205,40 @@ defmodule WhoNeedHelp.AccountsTest do end end + describe "staff roles and permissions" do + test "combines permissions from every assigned role and gives administrators all permissions" do + combined = staff_user_fixture([:support, :moderator]) + + assert Accounts.permission?(combined, :support_manage) + assert Accounts.permission?(combined, :moderation_manage) + assert Accounts.permission?(combined, :users_moderate) + refute Accounts.permission?(combined, :legal_manage) + refute Accounts.permission?(combined, :audit_view) + + admin = staff_user_fixture([:admin]) + + assert Enum.all?(StaffPermissions.permissions(), &Accounts.permission?(admin, &1)) + end + + test "a restricted staff account keeps assignments but loses effective authorization" do + staff = staff_user_fixture([:support, :legal]) + assert Accounts.authorized?(staff, :support_manage) + assert Accounts.authorized?(staff, :legal_manage) + + restricted = + staff + |> User.moderation_changeset(%{ + "moderation_status" => "restricted", + "moderation_note" => "Temporary staff access restriction" + }) + |> Repo.update!() + + assert Accounts.staff_roles(restricted) == [:legal, :support] + refute Accounts.authorized?(restricted, :support_manage) + refute Accounts.authorized?(restricted, :legal_manage) + end + end + describe "profile URL validation" do test "accepts only encrypted thank-you and social profile URLs" do user = user_fixture() diff --git a/test/who_need_help/activities_test.exs b/test/who_need_help/activities_test.exs index 09cf8fc..6fd4b4e 100644 --- a/test/who_need_help/activities_test.exs +++ b/test/who_need_help/activities_test.exs @@ -668,10 +668,7 @@ defmodule WhoNeedHelp.ActivitiesTest do end test "activity message reports expose only the linked group to audited moderators", context do - moderator = - user_fixture(display_name: "Activity moderator") - |> Ecto.Changeset.change(role: :moderator) - |> Repo.update!() + moderator = staff_user_fixture([:moderator], display_name: "Activity moderator") attrs = Map.put(context.attrs, "capacity", 3) {:ok, activity} = Activities.create_activity(context.organizer_scope, attrs) @@ -738,10 +735,7 @@ defmodule WhoNeedHelp.ActivitiesTest do end test "moderators can hide and restore a reported activity", context do - moderator = - user_fixture(display_name: "Activity moderator") - |> Ecto.Changeset.change(role: :moderator) - |> Repo.update!() + moderator = staff_user_fixture([:moderator], display_name: "Activity moderator") moderator_scope = user_scope_fixture(moderator) {:ok, activity} = Activities.create_activity(context.organizer_scope, context.attrs) diff --git a/test/who_need_help/product_analytics_test.exs b/test/who_need_help/product_analytics_test.exs index d3d2246..d44820c 100644 --- a/test/who_need_help/product_analytics_test.exs +++ b/test/who_need_help/product_analytics_test.exs @@ -23,9 +23,8 @@ defmodule WhoNeedHelp.ProductAnalyticsTest do assert ProductAnalytics.paginate(ordinary).entries == [] moderator = - user_fixture() - |> Ecto.Changeset.change(role: :moderator) - |> Repo.update!() + [:analyst] + |> staff_user_fixture() |> user_scope_fixture() assert [%DailyMetric{metric: "request.created", count: 2}] = diff --git a/test/who_need_help/support_and_content_removal_test.exs b/test/who_need_help/support_and_content_removal_test.exs index 9fb9827..ca49d99 100644 --- a/test/who_need_help/support_and_content_removal_test.exs +++ b/test/who_need_help/support_and_content_removal_test.exs @@ -173,10 +173,7 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do assert request.contact_verified_at assert_email_sent() - moderator = - user_fixture() - |> Ecto.Changeset.change(role: :moderator) - |> Repo.update!() + moderator = staff_user_fixture([:support, :legal]) assert_email_sent() @@ -206,6 +203,64 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do end) end + test "support and legal cases can be assigned only to active staff for the matching queue" do + requester = user_fixture() + requester_scope = user_scope_fixture(requester) + operator = staff_user_fixture([:support, :legal], display_name: "Queue operator") + support_assignee = staff_user_fixture([:support], display_name: "Support assignee") + legal_assignee = staff_user_fixture([:legal], display_name: "Legal assignee") + ordinary = user_fixture(display_name: "Ordinary account") + operator_scope = user_scope_fixture(operator) + + assert {:ok, support_request} = + Support.create_request(requester_scope, %{ + "kind" => "technical_issue", + "subject" => "Assign this support conversation", + "details" => "This verified support case should be assigned to a support operator." + }) + + assert {:error, :invalid_assignee} = + Support.moderate(operator_scope, support_request.id, %{ + "status" => "reviewing", + "assigned_to_id" => ordinary.id + }) + + assert {:ok, assigned_support} = + Support.moderate(operator_scope, support_request.id, %{ + "status" => "reviewing", + "assigned_to_id" => support_assignee.id + }) + + assert assigned_support.assigned_to_id == support_assignee.id + + assert {:ok, notice} = + ContentRemoval.create_notice(requester_scope, :general, %{ + "category" => "privacy_violation", + "submitter_name" => "Notice submitter", + "relationship" => "self", + "content_locations" => "https://example.test/requests/legal-assignment", + "explanation" => + "This notice exercises assignment of a verified legal-review workflow.", + "electronic_signature" => "Notice submitter", + "good_faith" => "true", + "accurate_complete" => "true" + }) + + assert {:error, :invalid_assignee} = + ContentRemoval.moderate(operator_scope, notice.id, %{ + "status" => "reviewing", + "assigned_to_id" => support_assignee.id + }) + + assert {:ok, assigned_notice} = + ContentRemoval.moderate(operator_scope, notice.id, %{ + "status" => "reviewing", + "assigned_to_id" => legal_assignee.id + }) + + assert assigned_notice.assigned_to_id == legal_assignee.id + end + test "requester replies reopen a finished case and preserve the full conversation history" do previous = Application.get_env(:who_need_help, :support_inbox_address) Application.put_env(:who_need_help, :support_inbox_address, "support@example.com") @@ -440,9 +495,7 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do end defp moderator_scope do - user_fixture() - |> Ecto.Changeset.change(role: :moderator) - |> Repo.update!() + staff_user_fixture([:support, :legal]) |> user_scope_fixture() end end diff --git a/test/who_need_help/trust_safety_test.exs b/test/who_need_help/trust_safety_test.exs index fef4541..6ad9ad4 100644 --- a/test/who_need_help/trust_safety_test.exs +++ b/test/who_need_help/trust_safety_test.exs @@ -3,7 +3,7 @@ defmodule WhoNeedHelp.TrustSafetyTest do import WhoNeedHelp.AccountsFixtures - alias WhoNeedHelp.{Catalog, Help, Messaging, Release, Tracking, Trust} + alias WhoNeedHelp.{Accounts, Catalog, Help, Messaging, Release, Tracking, Trust} alias WhoNeedHelp.Help.Assignment alias WhoNeedHelp.Repo alias WhoNeedHelp.Tracking.Position @@ -86,10 +86,7 @@ defmodule WhoNeedHelp.TrustSafetyTest do outsider = user_fixture(display_name: "Report history outsider") assert [] = Trust.list_reports_for_user(user_scope_fixture(outsider)) - moderator = - user_fixture(display_name: "Report history moderator") - |> Ecto.Changeset.change(role: :moderator) - |> Repo.update!() + moderator = staff_user_fixture([:moderator], display_name: "Report history moderator") assert {:ok, resolved} = Trust.moderate_report(user_scope_fixture(moderator), report.id, %{ @@ -113,15 +110,9 @@ defmodule WhoNeedHelp.TrustSafetyTest do end test "forged moderation identifiers return not found without crashing", context do - moderator = - user_fixture(display_name: "Moderator") - |> Ecto.Changeset.change(role: :moderator) - |> Repo.update!() + moderator = staff_user_fixture([:moderator], display_name: "Moderator") - admin = - user_fixture(display_name: "Administrator") - |> Ecto.Changeset.change(role: :admin) - |> Repo.update!() + admin = staff_user_fixture([:admin], display_name: "Administrator") moderator_scope = user_scope_fixture(moderator) admin_scope = user_scope_fixture(admin) @@ -136,7 +127,7 @@ defmodule WhoNeedHelp.TrustSafetyTest do assert {:error, :not_found} = Trust.hide_activity(moderator_scope, id, "review") assert {:error, :not_found} = Trust.restore_activity(moderator_scope, id) assert {:error, :not_found} = Trust.moderate_user(moderator_scope, id, %{}) - assert {:error, :not_found} = Trust.moderate_role(admin_scope, id, %{}) + assert {:error, :not_found} = Trust.set_staff_roles(admin_scope, id, []) assert {:error, :not_found} = Catalog.approve_proposal(moderator_scope, id, %{}) assert {:error, :not_found} = Catalog.reject_proposal(moderator_scope, id, "review") @@ -154,10 +145,7 @@ defmodule WhoNeedHelp.TrustSafetyTest do end test "hiding a request requires a moderation reason", context do - moderator = - user_fixture(display_name: "Reason moderator") - |> Ecto.Changeset.change(role: :moderator) - |> Repo.update!() + moderator = staff_user_fixture([:moderator], display_name: "Reason moderator") {:ok, request} = Help.create_request(context.requester_scope, context.attrs) @@ -169,10 +157,7 @@ defmodule WhoNeedHelp.TrustSafetyTest do end test "category moderation cannot cross help and activity taxonomies", context do - moderator = - user_fixture(display_name: "Category moderator") - |> Ecto.Changeset.change(role: :moderator) - |> Repo.update!() + moderator = staff_user_fixture([:moderator], display_name: "Category moderator") moderator_scope = user_scope_fixture(moderator) activity_category = Catalog.list_categories(:activity) |> List.first() @@ -334,10 +319,7 @@ defmodule WhoNeedHelp.TrustSafetyTest do end test "reports expose only linked evidence to moderators and audit that access", context do - moderator = - user_fixture(display_name: "Moderator") - |> Ecto.Changeset.change(role: :moderator) - |> Repo.update!() + moderator = staff_user_fixture([:moderator], display_name: "Moderator") moderator_scope = user_scope_fixture(moderator) {:ok, request} = Help.create_request(context.requester_scope, context.attrs) @@ -669,55 +651,39 @@ defmodule WhoNeedHelp.TrustSafetyTest do end test "only an administrator can change roles and the last admin is protected", context do - moderator = - user_fixture(display_name: "Moderator") - |> Ecto.Changeset.change(role: :moderator) - |> Repo.update!() + moderator = staff_user_fixture([:moderator], display_name: "Moderator") - admin = - user_fixture(display_name: "Administrator") - |> Ecto.Changeset.change(role: :admin) - |> Repo.update!() + admin = staff_user_fixture([:admin], display_name: "Administrator") assert {:error, :forbidden} = - Trust.moderate_role(user_scope_fixture(moderator), context.helper.id, %{ - "role" => "moderator" - }) + Trust.set_staff_roles(user_scope_fixture(moderator), context.helper.id, ["moderator"]) assert {:ok, promoted} = - Trust.moderate_role(user_scope_fixture(admin), context.helper.id, %{ - "role" => "moderator" - }) + Trust.set_staff_roles(user_scope_fixture(admin), context.helper.id, [ + "support", + "moderator" + ]) - assert promoted.role == :moderator + assert Accounts.loaded_staff_roles(promoted) == [:moderator, :support] assert {:error, :last_admin} = - Trust.moderate_role(user_scope_fixture(admin), admin.id, %{"role" => "user"}) + Trust.set_staff_roles(user_scope_fixture(admin), admin.id, []) assert Repo.exists?( from event in AuditEvent, where: event.actor_id == ^admin.id and event.target_id == ^context.helper.id and - event.action == "user.role_changed" + event.action == "user.staff_roles_changed" ) end test "restricted moderators lose authorization and moderators cannot suspend administrators", context do - admin = - user_fixture(display_name: "Administrator") - |> Ecto.Changeset.change(role: :admin) - |> Repo.update!() + admin = staff_user_fixture([:admin], display_name: "Administrator") - moderator = - user_fixture(display_name: "Moderator") - |> Ecto.Changeset.change(role: :moderator) - |> Repo.update!() + moderator = staff_user_fixture([:moderator], display_name: "Moderator") - active_moderator = - user_fixture(display_name: "Active moderator") - |> Ecto.Changeset.change(role: :moderator) - |> Repo.update!() + active_moderator = staff_user_fixture([:moderator], display_name: "Active moderator") assert WhoNeedHelp.Accounts.moderator_authorized?(moderator) @@ -776,15 +742,9 @@ defmodule WhoNeedHelp.TrustSafetyTest do end test "moderators cannot restrict staff and restrictions require an internal note", context do - admin = - user_fixture(display_name: "Staff administrator") - |> Ecto.Changeset.change(role: :admin) - |> Repo.update!() + admin = staff_user_fixture([:admin], display_name: "Staff administrator") - moderator = - user_fixture(display_name: "Staff moderator") - |> Ecto.Changeset.change(role: :moderator) - |> Repo.update!() + moderator = staff_user_fixture([:moderator], display_name: "Staff moderator") assert {:error, :forbidden} = Trust.moderate_user(user_scope_fixture(moderator), admin.id, %{ @@ -813,7 +773,8 @@ defmodule WhoNeedHelp.TrustSafetyTest do test "the first administrator bootstrap is one-time and audited", context do assert {:ok, admin} = Release.bootstrap_admin(context.helper.email) assert admin.id == context.helper.id - assert admin.role == :admin + assert admin.roles == [:admin] + assert Accounts.staff_roles(context.helper) == [:admin] assert {:error, :admin_already_exists} = Release.bootstrap_admin(context.requester.email) diff --git a/test/who_need_help_web/controllers/support_controller_test.exs b/test/who_need_help_web/controllers/support_controller_test.exs index 8b28c56..070ba11 100644 --- a/test/who_need_help_web/controllers/support_controller_test.exs +++ b/test/who_need_help_web/controllers/support_controller_test.exs @@ -216,10 +216,7 @@ defmodule WhoNeedHelpWeb.SupportControllerTest do "details" => "This request verifies the private support conversation flow." }) - moderator = - user_fixture() - |> Ecto.Changeset.change(role: :moderator) - |> WhoNeedHelp.Repo.update!() + moderator = staff_user_fixture([:support]) {:ok, _resolved} = WhoNeedHelp.Support.moderate(user_scope_fixture(moderator), request.id, %{ @@ -269,10 +266,7 @@ defmodule WhoNeedHelpWeb.SupportControllerTest do "details" => "Both connected support views must refresh without a browser reload." }) - moderator = - user_fixture() - |> Ecto.Changeset.change(role: :moderator) - |> WhoNeedHelp.Repo.update!() + moderator = staff_user_fixture([:support]) {:ok, requester_view, _html} = live(conn, ~p"/support/cases/#{request.id}") @@ -313,10 +307,7 @@ defmodule WhoNeedHelpWeb.SupportControllerTest do end test "renders separate support and removal queues for a moderator", %{conn: conn} do - moderator = - user_fixture() - |> Ecto.Changeset.change(role: :moderator) - |> WhoNeedHelp.Repo.update!() + moderator = staff_user_fixture([:support, :legal]) {:ok, support_request} = WhoNeedHelp.Support.create_request(nil, %{ diff --git a/test/who_need_help_web/live/admin_live_test.exs b/test/who_need_help_web/live/admin_live_test.exs new file mode 100644 index 0000000..24eb89e --- /dev/null +++ b/test/who_need_help_web/live/admin_live_test.exs @@ -0,0 +1,114 @@ +defmodule WhoNeedHelpWeb.AdminLiveTest do + use WhoNeedHelpWeb.ConnCase, async: false + + import Ecto.Query + import Phoenix.LiveViewTest + import WhoNeedHelp.AccountsFixtures + + alias WhoNeedHelp.{Accounts, Repo} + alias WhoNeedHelp.Trust.AuditEvent + + test "staff routes expose only the workspaces allowed by the union of assigned roles", %{ + conn: conn + } do + regular = user_fixture() + + assert {:error, {:redirect, %{to: "/requests"}}} = + conn |> log_in_user(regular) |> live(~p"/admin") + + support = staff_user_fixture([:support]) + support_conn = log_in_user(conn, support) + + assert {:ok, _view, html} = live(support_conn, ~p"/admin") + assert html =~ ">Support<" + refute html =~ "Support and legal" + refute html =~ "Users and roles" + refute html =~ "Trust and safety" + assert {:ok, _view, support_html} = live(support_conn, ~p"/support/operations") + assert support_html =~ "Support operations" + refute support_html =~ "Content removal and TAKE IT DOWN" + + assert {:error, {:redirect, %{to: "/requests"}}} = + live(support_conn, ~p"/admin/users") + + support_moderator = staff_user_fixture([:support, :moderator]) + combined_conn = log_in_user(conn, support_moderator) + + assert {:ok, _view, html} = live(combined_conn, ~p"/admin") + assert html =~ ">Support<" + refute html =~ "Support and legal" + assert html =~ "Users and roles" + assert html =~ "Trust and safety" + assert {:ok, _view, _html} = live(combined_conn, ~p"/support/operations") + assert {:ok, _view, _html} = live(combined_conn, ~p"/moderation") + assert {:ok, _view, _html} = live(combined_conn, ~p"/admin/users") + + assert {:error, {:redirect, %{to: "/requests"}}} = + live(combined_conn, ~p"/admin/audit") + end + + test "administrator can combine roles, suspend users, and inspect audited actions", %{ + conn: conn + } do + admin = staff_user_fixture([:admin], display_name: "Operations administrator") + target = user_fixture(display_name: "Account under review") + conn = log_in_user(conn, admin) + + {:ok, users_view, html} = live(conn, ~p"/admin/users") + assert html =~ "Users and staff access" + assert has_element?(users_view, "#staff-roles-#{target.id}") + + users_view + |> form("#staff-roles-#{target.id}", %{ + "staff" => %{"roles" => ["support", "moderator"]} + }) + |> render_submit() + + updated = target |> Repo.reload!() |> Accounts.preload_staff_roles() + assert Accounts.loaded_staff_roles(updated) == [:moderator, :support] + + users_view + |> form("#moderate-user-#{target.id}", %{ + "moderation" => %{ + "moderation_status" => "suspended", + "moderation_note" => "Confirmed administrative test restriction" + } + }) + |> render_submit() + + assert Repo.reload!(target).moderation_status == :suspended + + assert Repo.exists?( + from event in AuditEvent, + where: + event.actor_id == ^admin.id and event.target_id == ^target.id and + event.action == "user.staff_roles_changed" + ) + + assert Repo.exists?( + from event in AuditEvent, + where: + event.actor_id == ^admin.id and event.target_id == ^target.id and + event.action == "user.moderated" + ) + + {:ok, _audit_view, audit_html} = live(conn, ~p"/admin/audit") + assert audit_html =~ "user.staff_roles_changed" + assert audit_html =~ "user.moderated" + end + + test "administrator UI protects the final active administrator", %{conn: conn} do + admin = staff_user_fixture([:admin], display_name: "Only administrator") + conn = log_in_user(conn, admin) + + {:ok, view, _html} = live(conn, ~p"/admin/users") + + html = + view + |> form("#staff-roles-#{admin.id}", %{"staff" => %{"roles" => [""]}}) + |> render_submit() + + assert html =~ "last active administrator" + assert Accounts.staff_roles(admin) == [:admin] + end +end diff --git a/test/who_need_help_web/live/mutual_aid_live_test.exs b/test/who_need_help_web/live/mutual_aid_live_test.exs index a977eff..bad311c 100644 --- a/test/who_need_help_web/live/mutual_aid_live_test.exs +++ b/test/who_need_help_web/live/mutual_aid_live_test.exs @@ -33,10 +33,7 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do "details" => "Review this request and keep the report private." }) - moderator = - user_fixture(display_name: "Report moderator") - |> Ecto.Changeset.change(role: :moderator) - |> Repo.update!() + moderator = staff_user_fixture([:moderator], display_name: "Report moderator") {:ok, _report} = Trust.moderate_report(user_scope_fixture(moderator), report.id, %{ @@ -1502,11 +1499,12 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do assert {:error, {:redirect, %{to: "/requests"}}} = live(conn, ~p"/moderation") end - test "moderator dashboard renders reports, signals, proposals, and accounts", %{ - conn: conn, - user: user - } do - user |> Ecto.Changeset.change(role: :moderator) |> Repo.update!() + test "moderator workspace renders reports, signals, and proposals without account management", + %{ + conn: conn, + user: user + } do + grant_staff_roles(user, [:moderator]) category = Catalog.seed_defaults() requester = user_fixture(display_name: "Requester") helper = user_fixture(display_name: "Helper") @@ -1538,11 +1536,10 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do assert html =~ "Reports" assert html =~ "Open abuse signals" assert html =~ "Category proposals" - assert html =~ "Accounts" + refute html =~ "Users & roles" assert html =~ "Please review the matched conversation." assert html =~ "Bicycle repair" refute has_element?(view, "#moderation-user-status-#{user.id}") - assert html =~ "Administrator access is required to moderate staff accounts." assert has_element?( view, @@ -1562,7 +1559,7 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do user: user, scope: participant_scope } do - user |> Ecto.Changeset.change(role: :moderator) |> Repo.update!() + grant_staff_roles(user, [:moderator]) organizer = user_fixture(display_name: "Activity organizer") Catalog.seed_defaults() activity_category = Catalog.list_categories(:activity) |> List.first() @@ -1615,7 +1612,7 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do conn: conn, user: user } do - user |> Ecto.Changeset.change(role: :moderator) |> Repo.update!() + grant_staff_roles(user, [:moderator]) subject = user_fixture(display_name: "Signal subject") for sequence <- 1..25 do