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"}]}
- />
+
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
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", %{