From 164af00757bdb2a9e6a496817a27ca5ed48082f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 00:31:41 +0000 Subject: [PATCH 1/4] feat: name paste audiences through one sharing vocabulary The design doc collapses four overlapping sharing concepts into three settings that each read as a sentence: "Who can see this" on a paste, and "Sharing outside the team" and "Who can join" on a workspace. Add TextbinWeb.Sharing as the single place those words live, and move the paste editor, detail page and list onto it. Stored values (audience, external_sharing_policy, visibility) are unchanged: the API, the CLI and recorded audit metadata depend on them. - Badges read "Anyone with the link" instead of "Unlisted". - The detail sentence for a public paste says "Anyone can see this paste." - A forbidden audience in the editor names the workspace setting that turns it off. - Pastes.audience_allowed?/2 is now public so the editor disables options with the same rule the changeset enforces, instead of a copy. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019fnjMN7WpEjQJD2zveakV6 --- lib/textbin/pastes.ex | 14 ++- lib/textbin_web/live/ui/paste_live.ex | 53 +++-------- .../live/ui/paste_live/detail.html.heex | 10 +- .../live/ui/paste_live/index.html.heex | 2 +- .../live/ui/paste_live/new.html.heex | 2 +- lib/textbin_web/sharing.ex | 91 +++++++++++++++++++ test/textbin_web/live/ui/paste_live_test.exs | 7 +- test/textbin_web/sharing_test.exs | 28 ++++++ 8 files changed, 151 insertions(+), 56 deletions(-) create mode 100644 lib/textbin_web/sharing.ex create mode 100644 test/textbin_web/sharing_test.exs 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/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/sharing.ex b/lib/textbin_web/sharing.ex new file mode 100644 index 0000000..b1887e9 --- /dev/null +++ b/lib/textbin_web/sharing.ex @@ -0,0 +1,91 @@ +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 + + # The settings form saves immediately, and tightening 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 people an owner adds can find or open this workspace." + + 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/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/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 From d7d3ea65d0ca4c80b05d9c17a1f4aeea706fc0c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 00:32:54 +0000 Subject: [PATCH 2/4] feat: workspace settings ask "Who can join" and "Sharing outside the team" Replace the "Visibility" (Open/Private) and "External sharing" (Disabled/Unlisted links/Public) selects with the design doc's wording, and put a sentence under each that describes the option being considered. The form now re-renders on change so the sentence follows the selection before anything is saved. The sharing sentence states what tightening does to pastes that are already shared, because Organizations clamps their audience on save: "Members only" stops earlier links working and "Members may share a link" turns public pastes link-only. The design doc says tightening never changes existing pastes; the code does, and the wording follows the code. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019fnjMN7WpEjQJD2zveakV6 --- lib/textbin_web/live/ui/workspace_live.ex | 39 ++++++++++++------- .../live/ui/workspace_live_test.exs | 18 ++++++++- 2 files changed, 42 insertions(+), 15 deletions(-) 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/test/textbin_web/live/ui/workspace_live_test.exs b/test/textbin_web/live/ui/workspace_live_test.exs index 2924339..b06d3a4 100644 --- a/test/textbin_web/live/ui/workspace_live_test.exs +++ b/test/textbin_web/live/ui/workspace_live_test.exs @@ -158,6 +158,22 @@ 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 people an owner adds") + + 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 +235,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" From ea0620e1a3cf5267a2819392c2d0bce25955bc66 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 00:32:54 +0000 Subject: [PATCH 3/4] feat: name join and sharing settings the same way in lists and audit log Workspace lists, the new-workspace form and the organization overview say "Who can join: Invite only" or "Anyone in the organization" instead of the raw "open"/"private" value, with a building icon for open workspaces so the globe stays reserved for public pastes. Audit entries for these settings are titled "Who can join changed" and "Sharing outside the team changed" and describe the change with the same labels. Recorded metadata is unchanged, so older events render with the new words too. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019fnjMN7WpEjQJD2zveakV6 --- lib/textbin_web/live/ui/audit_log_live.ex | 16 +++---- lib/textbin_web/live/ui/organization_live.ex | 10 ++--- .../live/ui/workspace_management_live.ex | 30 ++++++------- .../live/ui/audit_log_live_test.exs | 42 ++++++++++++++++++- 4 files changed, 64 insertions(+), 34 deletions(-) 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/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/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 From e142beef8ca4416d11746dfe219046cd4db1a655 Mon Sep 17 00:00:00 2001 From: "Darwin D. Wu" Date: Sat, 10 Oct 2026 17:48:36 -0700 Subject: [PATCH 4/4] fix: explain retained membership in invite-only workspaces Describe current access separately from admission: existing members keep access when a workspace becomes invite-only; owners add new members. Update LiveView sentence coverage. Verified 567 tests, Credo, and rendered private/open preview states. --- lib/textbin_web/sharing.ex | 7 ++++--- test/textbin_web/live/ui/workspace_live_test.exs | 3 ++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/textbin_web/sharing.ex b/lib/textbin_web/sharing.ex index b1887e9..2babfae 100644 --- a/lib/textbin_web/sharing.ex +++ b/lib/textbin_web/sharing.ex @@ -56,8 +56,8 @@ defmodule TextbinWeb.Sharing do def sharing_policy_label("public"), do: "Members may also publish" def sharing_policy_label(policy), do: policy - # The settings form saves immediately, and tightening rewrites the pastes - # that are already shared (Organizations clamps their audience), so the + # 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." @@ -82,7 +82,8 @@ defmodule TextbinWeb.Sharing do do: "Everyone in the organization can find and join this workspace." def join_policy_sentence("private"), - do: "Only people an owner adds can find or open this workspace." + 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 diff --git a/test/textbin_web/live/ui/workspace_live_test.exs b/test/textbin_web/live/ui/workspace_live_test.exs index b06d3a4..02dd83b 100644 --- a/test/textbin_web/live/ui/workspace_live_test.exs +++ b/test/textbin_web/live/ui/workspace_live_test.exs @@ -161,7 +161,8 @@ defmodule TextbinWeb.UI.WorkspaceLiveTest do 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 people an owner adds") + 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", %{