neiam
/diogramos
diogramos
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
Delete a canvas from the manage modal, behind a typed confirmation
5602f02 · Gabriel Morell · 2026-09-16 04:03
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