Delete a canvas from the manage modal, behind a typed confirmation

5602f02 · Gabriel Morell · 2026-09-16 04:03

22 files +272 -22
Message
{commit_body(@commit)}

Files changed

modified README.md
+2 −1
@@ -11,7 +11,8 @@ site. Phoenix LiveView on top of PostgreSQL, with live multi-user editing.
11 11 `/canvases`.
12 12 - **Metadata links** — attach external (`https://…`) or canvas-to-canvas
13 13 links to an element; they render as side-by-side icons on the shape.
14 −- **Folders & organization** — group canvases into folders.
14 +- **Folders & organization** — group canvases into folders. Deleting a
15 + canvas is owner-only and asks for its slug to be typed back first.
15 16 - **Sharing & permissions** — role-based grants per folder/canvas, plus
16 17 one-off share links (`/s/:token`).
17 18 - **Embeds** — publish a canvas read-only via `/embed/:token` (and the
modified lib/diogramos/diagrams/canvases.ex
+6 −1
@@ -8,7 +8,7 @@ defmodule Diogramos.Diagrams.Canvases do
8 8
9 9 alias Diogramos.Repo
10 10 alias Diogramos.Accounts.{Scope, User}
11 − alias Diogramos.Diagrams.{Canvas, Permission, Permissions}
11 + alias Diogramos.Diagrams.{Authority, Canvas, Permission, Permissions}
12 12
13 13 @spec list(Scope.t()) :: [Canvas.t()]
14 14 def list(%Scope{user: %User{id: user_id}}) do
@@ -116,6 +116,11 @@ defmodule Diogramos.Diagrams.Canvases do
116 116 {:ok, Canvas.t()} | {:error, Ecto.Changeset.t() | :forbidden}
117 117 def delete(%Scope{} = scope, %Canvas{} = canvas) do
118 118 with :ok <- Permissions.authorize(scope, :admin, canvas) do
119 + # Stop the authority first. It persists on the way out, and
120 + # persisting a row that has just been deleted raises
121 + # Ecto.StaleEntryError out of terminate/2 -- a crash report for a
122 + # delete that otherwise worked.
123 + Authority.stop(canvas.id)
119 124 Repo.delete(canvas)
120 125 end
121 126 end
modified lib/diogramos/prom_ex.ex
+3 −1
@@ -42,7 +42,9 @@ defmodule Diogramos.PromEx do
42 42 # `path` is the *route pattern* from the router, not the request path, so
43 43 # the label stays bounded no matter how many distinct URLs are hit.
44 44 {Plugins.Phoenix,
45 − router: DiogramosWeb.Router, endpoint: DiogramosWeb.Endpoint, metric_prefix: [:phx, :phoenix]},
45 + router: DiogramosWeb.Router,
46 + endpoint: DiogramosWeb.Endpoint,
47 + metric_prefix: [:phx, :phoenix]},
46 48 {Plugins.Ecto, repos: [Diogramos.Repo], metric_prefix: [:phx, :ecto]}
47 49 ]
48 50 end
modified lib/diogramos_web/api/v1/canvas_controller.ex
+0 −1
@@ -71,7 +71,6 @@ defmodule DiogramosWeb.Api.V1.CanvasController do
71 71
72 72 with {:ok, canvas} <- fetch_canvas(scope, slug),
73 73 {:ok, _canvas} <- Diagrams.delete_canvas(scope, canvas) do
74 − Authority.stop(canvas.id)
75 74 send_resp(conn, :no_content, "")
76 75 end
77 76 end
modified lib/diogramos_web/live/canvas_live/edit.ex
+12 −4
@@ -1875,7 +1875,9 @@ defmodule DiogramosWeb.CanvasLive.Edit do
1875 1875 text-anchor={@geom.text_anchor}
1876 1876 dominant-baseline={@geom.baseline}
1877 1877 style={@text_style}
1878 − >{@element["label"]}</text>
1878 + >
1879 + {@element["label"]}
1880 + </text>
1879 1881 """
1880 1882 end
1881 1883 end
@@ -1886,7 +1888,9 @@ defmodule DiogramosWeb.CanvasLive.Edit do
1886 1888 family = font_family_css(get_in(element, ["style", "label_font_family"]) || "sans")
1887 1889 size = label_size_px(get_in(element, ["style", "label_size"]) || "md")
1888 1890 weight = if get_in(element, ["style", "label_bold"]) == true, do: "700", else: "400"
1889 − style_part = if get_in(element, ["style", "label_italic"]) == true, do: "italic", else: "normal"
1891 +
1892 + style_part =
1893 + if get_in(element, ["style", "label_italic"]) == true, do: "italic", else: "normal"
1890 1894
1891 1895 "fill: #{token_var(color)}; font-family: #{family}; font-size: #{size}px; font-weight: #{weight}; font-style: #{style_part};"
1892 1896 end
@@ -2806,7 +2810,9 @@ defmodule DiogramosWeb.CanvasLive.Edit do
2806 2810 :for={{label, value} <- @kind_options}
2807 2811 value={value}
2808 2812 selected={@link["kind"] == value}
2809 − >{label}</option>
2813 + >
2814 + {label}
2815 + </option>
2810 2816 </select>
2811 2817 </label>
2812 2818
@@ -2833,7 +2839,9 @@ defmodule DiogramosWeb.CanvasLive.Edit do
2833 2839 :for={{label, value} <- @icon_options}
2834 2840 value={value}
2835 2841 selected={@link["icon"] == value}
2836 − >{label}</option>
2842 + >
2843 + {label}
2844 + </option>
2837 2845 </select>
2838 2846 </label>
2839 2847 </div>
modified lib/diogramos_web/live/canvas_live/embed.ex
+3 −2
@@ -53,7 +53,6 @@ defmodule DiogramosWeb.CanvasLive.Embed do
53 53 end
54 54 end
55 55
56 −
57 56 @impl true
58 57 def handle_info({:canvas_op, %{op: op, version: version}}, socket) do
59 58 cond do
@@ -476,7 +475,9 @@ defmodule DiogramosWeb.CanvasLive.Embed do
476 475 text-anchor={@geom.text_anchor}
477 476 dominant-baseline={@geom.baseline}
478 477 style={@text_style}
479 − >{@element["label"]}</text>
478 + >
479 + {@element["label"]}
480 + </text>
480 481 """
481 482 end
482 483 end
modified lib/diogramos_web/live/canvas_live/index.ex
+102 −8
@@ -21,6 +21,7 @@ defmodule DiogramosWeb.CanvasLive.Index do
21 21 |> assign(:manage_canvas, nil)
22 22 |> assign(:manage_grants, [])
23 23 |> assign(:manage_error, nil)
24 + |> assign(:delete_confirm, "")
24 25 |> allow_upload(:canvas_import,
25 26 accept: ~w(.json application/json),
26 27 max_entries: 1,
@@ -135,7 +136,8 @@ defmodule DiogramosWeb.CanvasLive.Index do
135 136 socket
136 137 |> assign(:manage_canvas, canvas)
137 138 |> assign(:manage_grants, Diagrams.list_user_grants("canvas", canvas.id))
138 − |> assign(:manage_error, nil)}
139 + |> assign(:manage_error, nil)
140 + |> assign(:delete_confirm, "")}
139 141 else
140 142 {:noreply, put_flash(socket, :error, "Only the owner can manage collaborators.")}
141 143 end
@@ -146,7 +148,33 @@ defmodule DiogramosWeb.CanvasLive.Index do
146 148 socket
147 149 |> assign(:manage_canvas, nil)
148 150 |> assign(:manage_grants, [])
149 − |> assign(:manage_error, nil)}
151 + |> assign(:manage_error, nil)
152 + |> assign(:delete_confirm, "")}
153 + end
154 +
155 + def handle_event("validate_delete", %{"confirm" => confirm}, socket) do
156 + {:noreply, assign(socket, :delete_confirm, confirm)}
157 + end
158 +
159 + # The typed confirmation is checked here, not only by the disabled
160 + # button: the button is a convenience, and the event can be sent
161 + # without it.
162 + def handle_event("delete_canvas", %{"confirm" => confirm}, socket) do
163 + scope = socket.assigns.current_scope
164 +
165 + case socket.assigns.manage_canvas do
166 + nil ->
167 + # No canvas open: nothing to delete, and nothing to say about it.
168 + {:noreply, socket}
169 +
170 + canvas ->
171 + if String.trim(confirm) == canvas.slug do
172 + delete_managed_canvas(socket, scope, canvas)
173 + else
174 + {:noreply,
175 + assign(socket, :manage_error, "Type #{canvas.slug} exactly to confirm the delete.")}
176 + end
177 + end
150 178 end
151 179
152 180 def handle_event("grant_user", %{"email" => email, "role" => role}, socket)
@@ -220,8 +248,7 @@ defmodule DiogramosWeb.CanvasLive.Index do
220 248 if canvas do
221 249 Diagrams.revoke_permission("canvas", canvas.id, "user", uid)
222 250
223 − {:noreply,
224 − assign(socket, :manage_grants, Diagrams.list_user_grants("canvas", canvas.id))}
251 + {:noreply, assign(socket, :manage_grants, Diagrams.list_user_grants("canvas", canvas.id))}
225 252 else
226 253 {:noreply, socket}
227 254 end
@@ -456,7 +483,10 @@ defmodule DiogramosWeb.CanvasLive.Index do
456 483 </p>
457 484
458 485 <form id="import-canvas-form" phx-change="validate_import" phx-submit="submit_import">
459 − <.live_file_input upload={@uploads.canvas_import} class="file-input file-input-bordered w-full" />
486 + <.live_file_input
487 + upload={@uploads.canvas_import}
488 + class="file-input file-input-bordered w-full"
489 + />
460 490
461 491 <div :for={entry <- @uploads.canvas_import.entries} class="text-xs mt-2">
462 492 <span class="font-mono">{entry.client_name}</span>
@@ -530,7 +560,9 @@ defmodule DiogramosWeb.CanvasLive.Index do
530 560 </div>
531 561 </form>
532 562
533 − <h4 class="font-mono text-xs uppercase tracking-wider text-accent mb-2">People with access</h4>
563 + <h4 class="font-mono text-xs uppercase tracking-wider text-accent mb-2">
564 + People with access
565 + </h4>
534 566 <ul class="flex flex-col gap-1 mb-4">
535 567 <li class="flex items-center justify-between text-sm border border-base-300 rounded-box px-3 py-2 bg-base-100">
536 568 <div class="min-w-0">
@@ -544,7 +576,9 @@ defmodule DiogramosWeb.CanvasLive.Index do
544 576 class="flex items-center justify-between text-sm border border-base-300 rounded-box px-3 py-2"
545 577 >
546 578 <div class="min-w-0">
547 − <span class="font-mono truncate">{grant.user && grant.user.email || "Anonymous"}</span>
579 + <span class="font-mono truncate">
580 + {(grant.user && grant.user.email) || "Anonymous"}
581 + </span>
548 582 <span class="text-xs opacity-60 block">{grant.role}</span>
549 583 </div>
550 584 <button
@@ -559,7 +593,9 @@ defmodule DiogramosWeb.CanvasLive.Index do
559 593 </li>
560 594 </ul>
561 595
562 − <h4 class="font-mono text-xs uppercase tracking-wider text-accent mb-2">Add collaborator</h4>
596 + <h4 class="font-mono text-xs uppercase tracking-wider text-accent mb-2">
597 + Add collaborator
598 + </h4>
563 599 <form phx-submit="grant_user" class="flex flex-col gap-2" id="manage-grant-form">
564 600 <div class="flex gap-2">
565 601 <input
@@ -583,6 +619,44 @@ defmodule DiogramosWeb.CanvasLive.Index do
583 619 <% end %>
584 620 </form>
585 621
622 + <div class="divider mt-6 mb-2" />
623 +
624 + <h4 class="font-mono text-xs uppercase tracking-wider text-error mb-2">
625 + Danger zone
626 + </h4>
627 + <form
628 + phx-submit="delete_canvas"
629 + phx-change="validate_delete"
630 + id="manage-delete-form"
631 + class="flex flex-col gap-2"
632 + >
633 + <p class="text-sm opacity-80">
634 + Deleting removes this canvas, everything drawn on it, and everyone else's
635 + access to it. It cannot be undone.
636 + </p>
637 + <p class="text-sm">
638 + Type <span class="font-mono text-error">{@manage_canvas.slug}</span> to confirm.
639 + </p>
640 + <div class="flex gap-2">
641 + <input
642 + type="text"
643 + name="confirm"
644 + value={@delete_confirm}
645 + placeholder={@manage_canvas.slug}
646 + autocomplete="off"
647 + spellcheck="false"
648 + class="input input-bordered input-sm flex-1 font-mono"
649 + />
650 + <button
651 + type="submit"
652 + class="btn btn-error btn-sm"
653 + disabled={String.trim(@delete_confirm) != @manage_canvas.slug}
654 + >
655 + Delete canvas
656 + </button>
657 + </div>
658 + </form>
659 +
586 660 <div class="modal-action">
587 661 <button type="button" phx-click="close_manage_modal" class="btn btn-ghost">Done</button>
588 662 </div>
@@ -593,6 +667,26 @@ defmodule DiogramosWeb.CanvasLive.Index do
593 667 """
594 668 end
595 669
670 + defp delete_managed_canvas(socket, scope, canvas) do
671 + case Diagrams.delete_canvas(scope, canvas) do
672 + {:ok, _deleted} ->
673 + {:noreply,
674 + socket
675 + |> stream_delete(:canvases, canvas)
676 + |> assign(:manage_canvas, nil)
677 + |> assign(:manage_grants, [])
678 + |> assign(:manage_error, nil)
679 + |> assign(:delete_confirm, "")
680 + |> put_flash(:info, "Deleted #{canvas.title}.")}
681 +
682 + {:error, :forbidden} ->
683 + {:noreply, assign(socket, :manage_error, "Only the owner can delete this canvas.")}
684 +
685 + {:error, _reason} ->
686 + {:noreply, assign(socket, :manage_error, "Couldn't delete that canvas.")}
687 + end
688 + end
689 +
596 690 defp error_to_string(:too_large), do: "File is too large."
597 691 defp error_to_string(:not_accepted), do: "File must be JSON."
598 692 defp error_to_string(:too_many_files), do: "Only one file at a time."
modified lib/diogramos_web/live/user_live/invites.ex
+4 −3
@@ -6,7 +6,9 @@ defmodule DiogramosWeb.UserLive.Invites do
6 6 @impl true
7 7 def mount(_params, _session, socket) do
8 8 user = socket.assigns.current_scope.user
9 − {:ok, socket |> assign(:page_title, "Invites") |> stream(:invites, Accounts.list_invites(user))}
9 +
10 + {:ok,
11 + socket |> assign(:page_title, "Invites") |> stream(:invites, Accounts.list_invites(user))}
10 12 end
11 13
12 14 @impl true
@@ -73,8 +75,7 @@ defmodule DiogramosWeb.UserLive.Invites do
73 75 id="invites-empty"
74 76 class="hidden only:block text-center text-sm opacity-60 py-8"
75 77 >
76 − No invites yet. Click <em class="not-italic font-semibold">New invite</em>
77 − to create one.
78 + No invites yet. Click <em class="not-italic font-semibold">New invite</em> to create one.
78 79 </li>
79 80 <li
80 81 :for={{dom_id, invite} <- @streams.invites}
added test/diogramos_web/live/canvas_delete_test.exs
+135 −0
@@ -0,0 +1,135 @@
1 +defmodule DiogramosWeb.CanvasDeleteTest do
2 + use DiogramosWeb.ConnCase, async: true
3 +
4 + import Phoenix.LiveViewTest
5 + import Diogramos.AccountsFixtures
6 + import Diogramos.DiagramsFixtures
7 +
8 + alias Diogramos.Accounts.Scope
9 + alias Diogramos.Diagrams
10 +
11 + setup %{conn: conn} do
12 + owner = user_fixture()
13 + scope = Scope.for_user(owner)
14 + canvas = canvas_fixture(scope, %{title: "Doomed"})
15 +
16 + %{conn: log_in_user(conn, owner), owner: owner, scope: scope, canvas: canvas}
17 + end
18 +
19 + defp open_manage(lv, canvas) do
20 + lv |> element("button[phx-value-id='#{canvas.id}']") |> render_click()
21 + end
22 +
23 + describe "the danger zone" do
24 + test "asks for the slug before it will delete", %{conn: conn, canvas: canvas} do
25 + {:ok, lv, _html} = live(conn, ~p"/canvases")
26 +
27 + html = open_manage(lv, canvas)
28 +
29 + assert html =~ "Danger zone"
30 + assert html =~ "Type"
31 + assert html =~ canvas.slug
32 + # The button stays disabled until the slug is typed, so a stray
33 + # click can't delete anything.
34 + assert html =~ "disabled"
35 + end
36 +
37 + test "deletes when the slug matches", %{conn: conn, scope: scope, canvas: canvas} do
38 + {:ok, lv, _html} = live(conn, ~p"/canvases")
39 + open_manage(lv, canvas)
40 +
41 + html =
42 + lv
43 + |> form("#manage-delete-form", %{"confirm" => canvas.slug})
44 + |> render_submit()
45 +
46 + assert html =~ "Deleted Doomed."
47 + refute html =~ canvas.slug
48 + refute Diagrams.get_canvas_by_slug(scope, canvas.slug)
49 + end
50 +
51 + test "refuses anything that isn't the slug, and keeps the canvas", %{
52 + conn: conn,
53 + scope: scope,
54 + canvas: canvas
55 + } do
56 + {:ok, lv, _html} = live(conn, ~p"/canvases")
57 + open_manage(lv, canvas)
58 +
59 + for typed <- ["", " ", "Doomed", canvas.slug <> "x", String.upcase(canvas.slug)] do
60 + html =
61 + lv
62 + |> form("#manage-delete-form", %{"confirm" => typed})
63 + |> render_submit()
64 +
65 + assert html =~ "Type #{canvas.slug} exactly to confirm"
66 + assert Diagrams.get_canvas_by_slug(scope, canvas.slug)
67 + end
68 + end
69 +
70 + test "surrounding whitespace is forgiven", %{conn: conn, scope: scope, canvas: canvas} do
71 + {:ok, lv, _html} = live(conn, ~p"/canvases")
72 + open_manage(lv, canvas)
73 +
74 + # Pasting a slug tends to bring a space with it, and the typed
75 + # confirmation is there to prove intent, not typing accuracy.
76 + lv
77 + |> form("#manage-delete-form", %{"confirm" => " #{canvas.slug} "})
78 + |> render_submit()
79 +
80 + refute Diagrams.get_canvas_by_slug(scope, canvas.slug)
81 + end
82 +
83 + test "a delete event with no canvas open does nothing", %{
84 + conn: conn,
85 + scope: scope,
86 + canvas: canvas
87 + } do
88 + {:ok, lv, _html} = live(conn, ~p"/canvases")
89 +
90 + # The modal was never opened, so there is nothing to confirm
91 + # against -- and the slug alone must not be enough.
92 + render_click(lv, "delete_canvas", %{"confirm" => canvas.slug})
93 +
94 + assert Diagrams.get_canvas_by_slug(scope, canvas.slug)
95 + end
96 + end
97 +
98 + describe "access" do
99 + test "an editor sees the canvas but is offered no way to manage it", %{
100 + conn: conn,
101 + canvas: canvas
102 + } do
103 + editor = user_fixture()
104 + {:ok, _} = Diagrams.grant_permission("canvas", canvas.id, "user", editor.id, "editor")
105 +
106 + {:ok, lv, html} = live(log_in_user(conn, editor), ~p"/canvases")
107 +
108 + assert html =~ canvas.title
109 + refute has_element?(lv, "button[phx-value-id='#{canvas.id}']")
110 + end
111 +
112 + test "an editor sending the event by hand is refused", %{conn: conn, canvas: canvas} do
113 + editor = user_fixture()
114 + {:ok, _} = Diagrams.grant_permission("canvas", canvas.id, "user", editor.id, "editor")
115 +
116 + {:ok, lv, _html} = live(log_in_user(conn, editor), ~p"/canvases")
117 +
118 + # The button isn't rendered for them, so this is the interesting
119 + # path: the handler has to refuse it on its own.
120 + html = render_click(lv, "open_manage_modal", %{"id" => to_string(canvas.id)})
121 +
122 + assert html =~ "Only the owner can manage collaborators."
123 + refute html =~ "Danger zone"
124 + end
125 +
126 + test "an editor's delete event is refused by the context too", %{canvas: canvas} do
127 + editor = user_fixture()
128 + {:ok, _} = Diagrams.grant_permission("canvas", canvas.id, "user", editor.id, "editor")
129 +
130 + # Defence in depth: the modal is owner-only, and so is the delete.
131 + assert {:error, :forbidden} =
132 + Diagrams.delete_canvas(Scope.for_user(editor), canvas)
133 + end
134 + end
135 +end
modified test/diogramos_web/live/canvas_embed_test.exs
+5 −1
@@ -102,7 +102,11 @@ defmodule DiogramosWeb.CanvasEmbedTest do
102 102 end)
103 103 end
104 104
105 − test "embed cursor presence is filterable in the editor", %{conn: conn, canvas: canvas, owner: owner} do
105 + test "embed cursor presence is filterable in the editor", %{
106 + conn: conn,
107 + canvas: canvas,
108 + owner: owner
109 + } do
106 110 {:ok, embed_lv, _} = live(conn, ~p"/embed/#{canvas.embed_token}")
107 111 render_hook(embed_lv, "cursor", %{"x" => 50, "y" => 70})
108 112

Parents: dde1795