diff --git a/lib/textbin/pastes.ex b/lib/textbin/pastes.ex index eb1d19c..f2719c1 100644 --- a/lib/textbin/pastes.ex +++ b/lib/textbin/pastes.ex @@ -1098,10 +1098,16 @@ defmodule Textbin.Pastes do end end - defp audience_allowed?("workspace", _policy), do: true - defp audience_allowed?("unlisted", policy), do: policy in ["unlisted", "public"] - defp audience_allowed?("public", "public"), do: true - defp audience_allowed?(_audience, _policy), do: false + @doc """ + Whether a workspace's external sharing policy permits a paste audience. + + Public so the editor can show forbidden audiences as disabled using the same + rule the changeset enforces. + """ + def audience_allowed?("workspace", _policy), do: true + def audience_allowed?("unlisted", policy), do: policy in ["unlisted", "public"] + def audience_allowed?("public", "public"), do: true + def audience_allowed?(_audience, _policy), do: false defp lock_authorized_workspace(scope, workspace_id) do lock_authorized_workspace(scope, workspace_id, "FOR UPDATE") diff --git a/lib/textbin_web/live/ui/audit_log_live.ex b/lib/textbin_web/live/ui/audit_log_live.ex index 16d1a0a..c22f638 100644 --- a/lib/textbin_web/live/ui/audit_log_live.ex +++ b/lib/textbin_web/live/ui/audit_log_live.ex @@ -3,6 +3,7 @@ defmodule TextbinWeb.UI.AuditLogLive do alias Textbin.Organizations alias Textbin.Organizations.{AuditEvent, Organization} + alias TextbinWeb.Sharing @page_size 25 @@ -206,10 +207,10 @@ defmodule TextbinWeb.UI.AuditLogLive do defp event_title("workspace.membership.added"), do: "Workspace member added" defp event_title("workspace.membership.removed"), do: "Workspace member removed" defp event_title("workspace.membership.role_changed"), do: "Workspace role changed" - defp event_title("workspace.visibility_changed"), do: "Workspace visibility changed" + defp event_title("workspace.visibility_changed"), do: "Who can join changed" defp event_title("workspace.external_sharing_policy_changed"), - do: "External sharing policy changed" + do: "Sharing outside the team changed" defp event_title("workspace.recovery_window_changed"), do: "Recovery window changed" defp event_title("workspace.recovery_access_granted"), do: "Workspace access recovered" @@ -225,7 +226,7 @@ defmodule TextbinWeb.UI.AuditLogLive do action: "workspace.created", metadata: %{"visibility" => visibility} }), - do: "Created #{visibility_article(visibility)} #{visibility} workspace." + do: "Created a workspace. Who can join: #{Sharing.join_policy_label(visibility)}." defp event_description(%AuditEvent{ action: "workspace.deleted", @@ -289,13 +290,15 @@ defmodule TextbinWeb.UI.AuditLogLive do action: "workspace.visibility_changed", metadata: %{"old" => old, "new" => new} }), - do: "Changed workspace visibility from #{old} to #{new}." + do: + "Changed who can join from #{Sharing.join_policy_label(old)} to #{Sharing.join_policy_label(new)}." defp event_description(%AuditEvent{ action: "workspace.external_sharing_policy_changed", metadata: %{"old" => old, "new" => new} }), - do: "Changed external sharing from #{old} to #{new}." + do: + "Changed sharing outside the team from #{Sharing.sharing_policy_label(old)} to #{Sharing.sharing_policy_label(new)}." defp event_description(%AuditEvent{ action: "workspace.recovery_window_changed", @@ -337,9 +340,6 @@ defmodule TextbinWeb.UI.AuditLogLive do defp short_id(id) when is_binary(id), do: String.slice(id, 0, 8) defp short_id(_id), do: "unknown" - defp visibility_article("open"), do: "an" - defp visibility_article(_visibility), do: "a" - defp workspace_description(%{"workspace_name" => name}), do: " in “#{name}”" defp workspace_description(_metadata), do: "" diff --git a/lib/textbin_web/live/ui/organization_live.ex b/lib/textbin_web/live/ui/organization_live.ex index 21ca585..74037e0 100644 --- a/lib/textbin_web/live/ui/organization_live.ex +++ b/lib/textbin_web/live/ui/organization_live.ex @@ -4,6 +4,7 @@ defmodule TextbinWeb.UI.OrganizationLive do alias Textbin.Accounts alias Textbin.Organizations alias Textbin.Organizations.{Organization, OrganizationMembership, Policy} + alias TextbinWeb.Sharing @impl true def mount(_params, _session, socket), do: {:ok, socket} @@ -428,15 +429,10 @@ defmodule TextbinWeb.UI.OrganizationLive do
+ {Sharing.join_policy_sentence(@settings_form[:visibility].value)} +
++ {Sharing.sharing_policy_sentence(@settings_form[:external_sharing_policy].value)} +
<.input field={@settings_form[:recovery_window_days]} type="select" @@ -220,7 +231,7 @@ defmodule TextbinWeb.UI.WorkspaceLive do id="workspace-settings-readonly" class="text-sm text-base-content/60" > - Visibility: {String.capitalize(@current_scope.workspace.visibility)} · External sharing: {String.capitalize( + Who can join: {Sharing.join_policy_label(@current_scope.workspace.visibility)} · Sharing outside the team: {Sharing.sharing_policy_label( @current_scope.workspace.external_sharing_policy )} · Removed pastes kept for {@current_scope.workspace.recovery_window_days} days diff --git a/lib/textbin_web/live/ui/workspace_management_live.ex b/lib/textbin_web/live/ui/workspace_management_live.ex index 2690306..0ff8704 100644 --- a/lib/textbin_web/live/ui/workspace_management_live.ex +++ b/lib/textbin_web/live/ui/workspace_management_live.ex @@ -3,6 +3,7 @@ defmodule TextbinWeb.UI.WorkspaceManagementLive do alias Textbin.Organizations alias Textbin.Organizations.{Organization, Policy, Workspace} + alias TextbinWeb.Sharing @impl true def mount(_params, _session, socket), do: {:ok, socket} @@ -144,8 +145,8 @@ defmodule TextbinWeb.UI.WorkspaceManagementLive do Workspaces- Open workspaces are available to everyone in the organization. Private workspaces - appear only after an owner adds you. + Some workspaces are open to anyone in the organization. Invite-only workspaces + appear here once an owner adds you.
<.link @@ -183,12 +184,7 @@ defmodule TextbinWeb.UI.WorkspaceManagementLive do/{row.workspace.slug}
-- {row.workspace.visibility} workspace +
+ Who can join: {Sharing.join_policy_label(row.workspace.visibility)}
- Private workspaces stay hidden from organization members who have not been invited. +
+ {Sharing.join_policy_sentence(@workspace_form[:visibility].value)}
diff --git a/lib/textbin_web/sharing.ex b/lib/textbin_web/sharing.ex new file mode 100644 index 0000000..2babfae --- /dev/null +++ b/lib/textbin_web/sharing.ex @@ -0,0 +1,92 @@ +defmodule TextbinWeb.Sharing do + @moduledoc """ + The words the interface uses for who can reach a paste or a workspace. + + The design names three settings, each read as a sentence: **Who can see + this** on a paste, and **Sharing outside the team** and **Who can join** on + a workspace. The stored values (`audience`, `external_sharing_policy` and + `visibility`) predate that vocabulary and stay as they are, because the API, + the CLI and recorded audit metadata depend on them. Screens name them only + through this module so the same setting reads the same everywhere. + + Unknown values fall back to the raw value rather than raising, because audit + metadata can outlive the set of values the code knows about. + """ + + @audiences ["workspace", "unlisted", "public"] + @sharing_policies ["disabled", "unlisted", "public"] + @join_policies ["open", "private"] + + ## Who can see this (per paste) + + def audiences, do: @audiences + + def audience_label("workspace"), do: "Workspace" + def audience_label("unlisted"), do: "Anyone with the link" + def audience_label("public"), do: "Public" + def audience_label(audience), do: audience + + def audience_description("workspace"), do: "Everyone in the workspace" + def audience_description("unlisted"), do: "Not listed anywhere; the link is the key" + def audience_description("public"), do: "Anyone, signed in or not" + def audience_description(_audience), do: nil + + def audience_icon("public"), do: "hero-globe-alt" + def audience_icon("unlisted"), do: "hero-link" + def audience_icon(_audience), do: "hero-user-group" + + def audience_sentence("workspace", workspace_name) when is_binary(workspace_name), + do: "Everyone in #{workspace_name} can see this paste." + + def audience_sentence("workspace", _workspace_name), + do: "Everyone in the workspace can see this paste." + + def audience_sentence("unlisted", _workspace_name), + do: "Anyone with the link can see this paste." + + def audience_sentence("public", _workspace_name), do: "Anyone can see this paste." + + ## Sharing outside the team (per workspace) + + def sharing_policy_options, + do: Enum.map(@sharing_policies, &{sharing_policy_label(&1), &1}) + + def sharing_policy_label("disabled"), do: "Members only" + def sharing_policy_label("unlisted"), do: "Members may share a link" + def sharing_policy_label("public"), do: "Members may also publish" + def sharing_policy_label(policy), do: policy + + # Saving tighter settings rewrites the pastes that are already shared + # (Organizations clamps their audience), so the + # consequence is spelled out next to the control rather than discovered. + def sharing_policy_sentence("disabled"), + do: "Pastes stay inside the workspace. Links shared earlier stop working." + + def sharing_policy_sentence("unlisted"), + do: "Members can give people outside the team a link. Public pastes become link-only." + + def sharing_policy_sentence("public"), + do: "Members can share a link or make a paste public." + + def sharing_policy_sentence(_policy), do: nil + + ## Who can join (per workspace) + + def join_policy_options, do: Enum.map(@join_policies, &{join_policy_label(&1), &1}) + + def join_policy_label("open"), do: "Anyone in the organization" + def join_policy_label("private"), do: "Invite only" + def join_policy_label(visibility), do: visibility + + def join_policy_sentence("open"), + do: "Everyone in the organization can find and join this workspace." + + def join_policy_sentence("private"), + do: + "Only workspace members can find and open this workspace. Existing members keep access; an owner must add new members." + + def join_policy_sentence(_visibility), do: nil + + def join_policy_icon("private"), do: "hero-lock-closed" + def join_policy_icon(_visibility), do: "hero-building-office-2" +end diff --git a/test/textbin_web/live/ui/audit_log_live_test.exs b/test/textbin_web/live/ui/audit_log_live_test.exs index 2763ce9..e1ce52d 100644 --- a/test/textbin_web/live/ui/audit_log_live_test.exs +++ b/test/textbin_web/live/ui/audit_log_live_test.exs @@ -54,7 +54,13 @@ defmodule TextbinWeb.UI.AuditLogLiveTest do assert has_element?(view, "#organization-audit-log-page") assert has_element?(view, "#audit-events") assert has_element?(view, "#audit-events article", "Workspace created") - assert has_element?(view, "#audit-events article", "Created a private workspace.") + + assert has_element?( + view, + "#audit-events article", + "Created a workspace. Who can join: Invite only." + ) + assert has_element?(view, "#audit-events article .break-all", context.owner.email) assert has_element?( @@ -67,6 +73,32 @@ defmodule TextbinWeb.UI.AuditLogLiveTest do assert has_element?(view, "#mobile-navigation-more[aria-current='page']") end + test "sharing setting changes read in the settings' own words", context do + {:ok, _workspace} = + Organizations.change_workspace_settings(context.owner_scope, context.workspace, %{ + visibility: "open", + external_sharing_policy: "unlisted" + }) + + {:ok, view, _html} = live(context.conn, audit_log_path(context.organization)) + + assert has_element?(view, "#audit-events article", "Who can join changed") + + assert has_element?( + view, + "#audit-events article", + "Changed who can join from Invite only to Anyone in the organization." + ) + + assert has_element?(view, "#audit-events article", "Sharing outside the team changed") + + assert has_element?( + view, + "#audit-events article", + "Changed sharing outside the team from Members only to Members may share a link." + ) + end + test "keeps actor, target, and workspace labels from the time of the event", context do actor_email = context.owner.email target_email = context.member.email @@ -154,7 +186,13 @@ defmodule TextbinWeb.UI.AuditLogLiveTest do {:ok, view, _html} = live(context.conn, audit_log_path(context.organization)) assert element_count(view, "#audit-events article") == 25 - assert has_element?(view, "#audit-events article", "Created an open workspace.") + + assert has_element?( + view, + "#audit-events article", + "Created a workspace. Who can join: Anyone in the organization." + ) + assert has_element?(view, "#load-more-audit-events") view diff --git a/test/textbin_web/live/ui/paste_live_test.exs b/test/textbin_web/live/ui/paste_live_test.exs index cb0bae3..f6133d6 100644 --- a/test/textbin_web/live/ui/paste_live_test.exs +++ b/test/textbin_web/live/ui/paste_live_test.exs @@ -337,7 +337,7 @@ defmodule TextbinWeb.UI.PasteLiveTest do assert has_element?(view, "#paste_audience_workspace:not([disabled])") assert has_element?(view, "#paste_audience_unlisted:not([disabled])") assert has_element?(view, "#paste_audience_public[disabled]") - assert has_element?(view, "#paste-audience", "Not allowed by the workspace sharing policy") + assert has_element?(view, "#paste-audience", "Sharing outside the team") end test "the editor spells out the removal date for the chosen expiry", %{ @@ -496,7 +496,7 @@ defmodule TextbinWeb.UI.PasteLiveTest do end test "anonymous viewers can open unlisted and public pastes", %{scope: scope} do - for visibility <- ["unlisted", "public"] do + for {visibility, label} <- [{"unlisted", "Anyone with the link"}, {"public", "Public"}] do {:ok, paste} = Pastes.create_paste(scope, %{ data: "#{visibility} paste", @@ -506,7 +506,8 @@ defmodule TextbinWeb.UI.PasteLiveTest do {:ok, view, _html} = live(build_conn(), ~p"/pastes/#{paste.id}") - assert has_element?(view, "#paste-audience", String.capitalize(visibility)) + assert has_element?(view, "#paste-audience", label) + assert has_element?(view, "#paste-audience-sentence", "Anyone") assert has_element?(view, "#paste-data .l-line[data-line='1']") assert has_element?(view, "#paste-data", "#{visibility} paste") assert has_element?(view, "#copy-paste-content[phx-hook='CopyToClipboard']") diff --git a/test/textbin_web/live/ui/workspace_live_test.exs b/test/textbin_web/live/ui/workspace_live_test.exs index 2924339..02dd83b 100644 --- a/test/textbin_web/live/ui/workspace_live_test.exs +++ b/test/textbin_web/live/ui/workspace_live_test.exs @@ -158,6 +158,23 @@ defmodule TextbinWeb.UI.WorkspaceLiveTest do assert Repo.reload!(context.workspace).visibility == "open" end + test "each sharing setting explains the option being considered", context do + {:ok, view, _html} = live(context.conn, workspace_path(context, "settings")) + + assert has_element?(view, "#workspace-join-policy-sentence", "Only workspace members") + assert has_element?(view, "#workspace-join-policy-sentence", "Existing members keep access") + + view + |> form("#workspace-settings-form", %{ + "workspace" => %{"visibility" => "open", "external_sharing_policy" => "disabled"} + }) + |> render_change() + + assert has_element?(view, "#workspace-join-policy-sentence", "Everyone in the organization") + assert has_element?(view, "#workspace-sharing-policy-sentence", "Links shared earlier stop") + assert Repo.reload!(context.workspace).visibility == "private" + end + test "workspace owners tighten external sharing and clamp existing paste audiences", context do old_updated_at = DateTime.add(DateTime.utc_now(), -60, :second) @@ -219,7 +236,7 @@ defmodule TextbinWeb.UI.WorkspaceLiveTest do conn = log_in_user(build_conn(), member) {:ok, view, _html} = live(conn, workspace_path(context, "settings")) - assert has_element?(view, "#workspace-settings-readonly", "Private") + assert has_element?(view, "#workspace-settings-readonly", "Who can join: Invite only") refute has_element?(view, "#workspace-settings-form") render_submit(view, "update_settings", %{"workspace" => %{"visibility" => "open"}}) assert Repo.reload!(context.workspace).visibility == "private" diff --git a/test/textbin_web/sharing_test.exs b/test/textbin_web/sharing_test.exs new file mode 100644 index 0000000..d4260c4 --- /dev/null +++ b/test/textbin_web/sharing_test.exs @@ -0,0 +1,28 @@ +defmodule TextbinWeb.SharingTest do + use ExUnit.Case, async: true + + alias TextbinWeb.Sharing + + test "every stored value has a label and a sentence" do + for audience <- Sharing.audiences() do + assert Sharing.audience_label(audience) != audience + assert Sharing.audience_sentence(audience, "Docs") =~ "can see this paste" + end + + for {label, policy} <- Sharing.sharing_policy_options() do + assert label != policy + assert is_binary(Sharing.sharing_policy_sentence(policy)) + end + + for {label, visibility} <- Sharing.join_policy_options() do + assert label != visibility + assert is_binary(Sharing.join_policy_sentence(visibility)) + end + end + + test "unknown values from old audit metadata fall back to the raw value" do + assert Sharing.sharing_policy_label("retired") == "retired" + assert Sharing.join_policy_label("retired") == "retired" + assert Sharing.audience_label("retired") == "retired" + end +end