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
<.icon - name={ - if(workspace.visibility == "private", - do: "hero-lock-closed", - else: "hero-globe-alt" - ) - } + name={Sharing.join_policy_icon(workspace.visibility)} class="size-4" /> - {String.capitalize(workspace.visibility)} + {Sharing.join_policy_label(workspace.visibility)}
diff --git a/lib/textbin_web/live/ui/paste_live.ex b/lib/textbin_web/live/ui/paste_live.ex index 41427b4..d78d5ed 100644 --- a/lib/textbin_web/live/ui/paste_live.ex +++ b/lib/textbin_web/live/ui/paste_live.ex @@ -8,6 +8,7 @@ defmodule TextbinWeb.UI.PasteLive do alias Textbin.Pastes alias Textbin.Pastes.ContentType alias Textbin.Pastes.Paste + alias TextbinWeb.Sharing alias TextbinWeb.UI.Removal embed_templates "paste_live/*" @@ -347,47 +348,21 @@ defmodule TextbinWeb.UI.PasteLive do defp audience_options(scope) do policy = scope.workspace.external_sharing_policy - [ - audience_option("workspace", true), - audience_option("unlisted", policy in ["unlisted", "public"]), - audience_option("public", policy == "public") - ] + for audience <- Sharing.audiences() do + %{ + value: audience, + label: Sharing.audience_label(audience), + description: Sharing.audience_description(audience), + allowed?: Pastes.audience_allowed?(audience, policy) + } + end end - defp audience_option("workspace", allowed?), - do: %{ - value: "workspace", - label: "Workspace", - description: "Everyone in the workspace", - allowed?: allowed? - } - - defp audience_option("unlisted", allowed?), - do: %{ - value: "unlisted", - label: "Anyone with the link", - description: "Not listed anywhere; the link is the key", - allowed?: allowed? - } - - defp audience_option("public", allowed?), - do: %{ - value: "public", - label: "Public", - description: "Open to everyone", - allowed?: allowed? - } - - defp audience_sentence(%Paste{audience: "workspace"}, %{workspace: %{name: name}}), - do: "Everyone in #{name} can see this paste." - - defp audience_sentence(%Paste{audience: "workspace"}, _scope), - do: "Everyone in the workspace can see this paste." - - defp audience_sentence(%Paste{audience: "unlisted"}, _scope), - do: "Anyone with the link can see this paste." - - defp audience_sentence(%Paste{audience: "public"}, _scope), do: "This paste is public." + defp audience_sentence(%Paste{audience: audience}, scope), + do: Sharing.audience_sentence(audience, scope_workspace_name(scope)) + + defp scope_workspace_name(%{workspace: %{name: name}}), do: name + defp scope_workspace_name(_scope), do: nil defp expiry_sentence(nil), do: "It never expires." diff --git a/lib/textbin_web/live/ui/paste_live/detail.html.heex b/lib/textbin_web/live/ui/paste_live/detail.html.heex index 7bab4ef..a139d44 100644 --- a/lib/textbin_web/live/ui/paste_live/detail.html.heex +++ b/lib/textbin_web/live/ui/paste_live/detail.html.heex @@ -114,13 +114,7 @@ class="flex flex-wrap items-center gap-x-3 gap-y-2 rounded-lg border border-base-300 bg-base-100 px-4 py-3 text-sm text-base-content/80" > <.icon - name={ - case @paste.audience do - "public" -> "hero-globe-alt" - "unlisted" -> "hero-link" - _audience -> "hero-user-group" - end - } + name={Sharing.audience_icon(@paste.audience)} class="size-4 shrink-0 text-base-content/50" /> @@ -128,7 +122,7 @@ - {String.capitalize(@paste.audience)} + {Sharing.audience_label(@paste.audience)} <%= if @paste.expires_at do %> diff --git a/lib/textbin_web/live/ui/workspace_live.ex b/lib/textbin_web/live/ui/workspace_live.ex index 5a803be..78e1120 100644 --- a/lib/textbin_web/live/ui/workspace_live.ex +++ b/lib/textbin_web/live/ui/workspace_live.ex @@ -4,6 +4,7 @@ defmodule TextbinWeb.UI.WorkspaceLive do alias Textbin.Accounts alias Textbin.Organizations alias Textbin.Organizations.{Policy, Workspace, WorkspaceMembership} + alias TextbinWeb.Sharing @impl true def mount(_params, _session, socket), do: {:ok, socket} @@ -112,6 +113,12 @@ defmodule TextbinWeb.UI.WorkspaceLive do end end + # Re-render on change so the sentence under each setting describes the + # option being considered, before anything is saved. + def handle_event("change_settings", %{"workspace" => workspace_params}, socket) do + {:noreply, assign(socket, :settings_form, to_form(workspace_params, as: :workspace))} + end + def handle_event("update_settings", %{"workspace" => workspace_params}, socket) do case Organizations.change_workspace_settings( socket.assigns.current_scope, @@ -187,25 +194,29 @@ defmodule TextbinWeb.UI.WorkspaceLive do :if={@workspace_owner?} for={@settings_form} id="workspace-settings-form" + phx-change="change_settings" phx-submit="update_settings" > - <.input - :if={!@current_scope.workspace.is_default} - field={@settings_form[:visibility]} - type="select" - label="Visibility" - options={[{"Open", "open"}, {"Private", "private"}]} - /> +
+ <.input + field={@settings_form[:visibility]} + type="select" + label="Who can join" + options={Sharing.join_policy_options()} + /> +

+ {Sharing.join_policy_sentence(@settings_form[:visibility].value)} +

+
<.input field={@settings_form[:external_sharing_policy]} type="select" - label="External sharing" - options={[ - {"Disabled", "disabled"}, - {"Unlisted links", "unlisted"}, - {"Public", "public"} - ]} + label="Sharing outside the team" + options={Sharing.sharing_policy_options()} /> +

+ {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
<.icon - name={ - if(row.workspace.visibility == "private", - do: "hero-lock-closed", - else: "hero-globe-alt" - ) - } + name={Sharing.join_policy_icon(row.workspace.visibility)} class="size-5" />
@@ -203,8 +199,8 @@ defmodule TextbinWeb.UI.WorkspaceManagementLive do

/{row.workspace.slug}

-

- {row.workspace.visibility} workspace +

+ Who can join: {Sharing.join_policy_label(row.workspace.visibility)}

@@ -311,14 +307,14 @@ defmodule TextbinWeb.UI.WorkspaceManagementLive do <.input field={@workspace_form[:visibility]} type="select" - label="Who can find and join this workspace?" - options={[ - {"Everyone in the organization", "open"}, - {"Invited members only", "private"} - ]} + label="Who can join" + options={Sharing.join_policy_options()} /> -

- 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