neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
Add a "Desktop widget" preset to the API tokens page
e7443bd · Gabriel Morell · 2026-09-26 17:24
Message
{commit_body(@commit)}
Files changed
modified
docs/mobile-api.md
+4
−0
@@ -43,6 +43,10 @@ needs. It is **not a scope** — the "Mobile app" checkbox on the tokens
| 43 | 43 | page ticks the individual scopes, so revoking one surface later doesn't |
| 44 | 44 | mean re-pairing the device. |
| 45 | 45 | |
| 46 | +`PersonalAccessToken.desktop_scopes/0` (`repo:read`, `ci:read`, | |
| 47 | +`ci:write`) is the same idea for the Plasma CI widget, behind the | |
| 48 | +"Desktop widget" checkbox. The mobile bundle is a superset of it. | |
| 49 | + | |
| 46 | 50 | ## Errors |
| 47 | 51 | |
| 48 | 52 | Every 4xx is `{"message": "..."}` with a reason worth showing a user |
modified
lib/git_gud/accounts/personal_access_token.ex
+23
−3
@@ -12,8 +12,9 @@ defmodule GitGud.Accounts.PersonalAccessToken do
| 12 | 12 | wants `ci:read` and nothing else; a comment bot wants `issues:write` |
| 13 | 13 | without the ability to merge anything. |
| 14 | 14 | |
| 15 | − `mobile_scopes/0` is the bundle the Android client needs. It's a | |
| 16 | − convenience for the token UI, not a scope of its own: a paired phone | |
| 15 | + `mobile_scopes/0` is the bundle the Android client needs, and | |
| 16 | + `desktop_scopes/0` the one the Plasma CI widget needs. They're a | |
| 17 | + convenience for the token UI, not scopes of their own: a paired device | |
| 17 | 18 | carries the individual scopes, so revoking one surface later doesn't |
| 18 | 19 | mean re-pairing. |
| 19 | 20 | """ |
@@ -56,6 +57,15 @@ defmodule GitGud.Accounts.PersonalAccessToken do
| 56 | 57 | packages:read |
| 57 | 58 | ) |
| 58 | 59 | |
| 60 | + # What the Plasma CI widget needs: list the repos it can watch, read | |
| 61 | + # their runs and logs, and re-run or cancel from the panel. The mobile | |
| 62 | + # bundle is a superset, so a phone token works in the widget too. | |
| 63 | + @desktop_scopes ~w( | |
| 64 | + repo:read | |
| 65 | + ci:read | |
| 66 | + ci:write | |
| 67 | + ) | |
| 68 | + | |
| 59 | 69 | @descriptions %{ |
| 60 | 70 | "repo" => "Create, import and delete repositories", |
| 61 | 71 | "repo:read" => "List and read repository metadata", |
@@ -99,9 +109,19 @@ defmodule GitGud.Accounts.PersonalAccessToken do
| 99 | 109 | @doc "The scopes a paired mobile device needs, as one bundle." |
| 100 | 110 | def mobile_scopes, do: @mobile_scopes |
| 101 | 111 | |
| 112 | + @doc "The scopes the Plasma CI widget needs, as one bundle." | |
| 113 | + def desktop_scopes, do: @desktop_scopes | |
| 114 | + | |
| 102 | 115 | @doc "Human-readable one-liner for `scope`, for the token UI." |
| 103 | 116 | def describe(scope), do: Map.get(@descriptions, scope, scope) |
| 104 | 117 | |
| 118 | + @doc "True when `scopes` already covers every scope in `bundle`." | |
| 119 | + def covers?(scopes, bundle) when is_list(scopes) and is_list(bundle), | |
| 120 | + do: Enum.all?(bundle, &(&1 in scopes)) | |
| 121 | + | |
| 105 | 122 | @doc "True when `scopes` already covers the whole mobile bundle." |
| 106 | − def mobile?(scopes) when is_list(scopes), do: Enum.all?(@mobile_scopes, &(&1 in scopes)) | |
| 123 | + def mobile?(scopes) when is_list(scopes), do: covers?(scopes, @mobile_scopes) | |
| 124 | + | |
| 125 | + @doc "True when `scopes` already covers the whole desktop-widget bundle." | |
| 126 | + def desktop?(scopes) when is_list(scopes), do: covers?(scopes, @desktop_scopes) | |
| 107 | 127 | end |
modified
lib/git_gud_web/live/personal_access_token_live/index.ex
+65
−19
@@ -8,10 +8,10 @@ defmodule GitGudWeb.PersonalAccessTokenLive.Index do
| 8 | 8 | minted at the narrowness the job actually needs. |
| 9 | 9 | |
| 10 | 10 | Scope selection is driven from an assign rather than raw checkboxes, |
| 11 | − because the **Mobile app** toggle has to tick and untick a whole | |
| 12 | − bundle of them at once. That bundle is not itself a scope: a paired | |
| 13 | − phone carries the individual scopes, so revoking one surface later | |
| 14 | − (say, dropping `pulls:write`) doesn't mean re-pairing the device. | |
| 11 | + because the **Mobile app** and **Desktop widget** toggles each tick | |
| 12 | + and untick a whole bundle of them at once. A bundle is not itself a | |
| 13 | + scope: a paired device carries the individual scopes, so revoking one | |
| 14 | + surface later (say, dropping `pulls:write`) doesn't mean re-pairing. | |
| 15 | 15 | |
| 16 | 16 | When a freshly-minted token covers the mobile bundle, the page renders |
| 17 | 17 | a `gitgud://pair?base=…&token=…` QR alongside it — scanning that from |
@@ -31,7 +31,7 @@ defmodule GitGudWeb.PersonalAccessTokenLive.Index do
| 31 | 31 | socket |
| 32 | 32 | |> assign(:user, user) |
| 33 | 33 | |> assign(:all_scopes, PersonalAccessToken.scopes()) |
| 34 | − |> assign(:mobile_scopes, PersonalAccessToken.mobile_scopes()) | |
| 34 | + |> assign(:bundles, bundles()) | |
| 35 | 35 | |> assign(:selected_scopes, MapSet.new(["repo"])) |
| 36 | 36 | |> assign(:new_token, nil) |
| 37 | 37 | |> assign(:api_base, GitGudWeb.Endpoint.url()) |
@@ -39,6 +39,28 @@ defmodule GitGudWeb.PersonalAccessTokenLive.Index do
| 39 | 39 | |> assign(:page_title, "API tokens")} |
| 40 | 40 | end |
| 41 | 41 | |
| 42 | + # The preset toggles above the scope grid, in display order. | |
| 43 | + defp bundles do | |
| 44 | + [ | |
| 45 | + %{ | |
| 46 | + key: "mobile", | |
| 47 | + label: "Mobile app", | |
| 48 | + blurb: | |
| 49 | + "Everything the GitGud Android client needs — browse repos, watch and re-run CI, " <> | |
| 50 | + "read and comment on issues and pull requests, read wikis.", | |
| 51 | + scopes: PersonalAccessToken.mobile_scopes() | |
| 52 | + }, | |
| 53 | + %{ | |
| 54 | + key: "desktop", | |
| 55 | + label: "Desktop widget", | |
| 56 | + blurb: | |
| 57 | + "What the GitGud CI Plasma widget needs — list repos, watch runs and their logs, " <> | |
| 58 | + "re-run and cancel from the panel.", | |
| 59 | + scopes: PersonalAccessToken.desktop_scopes() | |
| 60 | + } | |
| 61 | + ] | |
| 62 | + end | |
| 63 | + | |
| 42 | 64 | defp load(socket) do |
| 43 | 65 | assign(socket, :tokens, Accounts.list_personal_access_tokens(socket.assigns.user)) |
| 44 | 66 | end |
@@ -49,16 +71,16 @@ defmodule GitGudWeb.PersonalAccessTokenLive.Index do
| 49 | 71 | end |
| 50 | 72 | |
| 51 | 73 | @doc """ |
| 52 | − Tick the whole mobile bundle, or clear it. "Clear" only removes the | |
| 53 | − bundle's own scopes — a token that also carries `repo` keeps it, since | |
| 54 | − the user asked for that separately. | |
| 74 | + Tick a whole bundle, or clear it. "Clear" only removes the bundle's | |
| 75 | + own scopes — a token that also carries `repo` keeps it, since the user | |
| 76 | + asked for that separately. | |
| 55 | 77 | """ |
| 56 | − def handle_event("toggle_mobile", _params, socket) do | |
| 57 | − bundle = socket.assigns.mobile_scopes | |
| 78 | + def handle_event("toggle_bundle", %{"bundle" => key}, socket) do | |
| 79 | + bundle = Enum.find(socket.assigns.bundles, &(&1.key == key)).scopes | |
| 58 | 80 | selected = socket.assigns.selected_scopes |
| 59 | 81 | |
| 60 | 82 | updated = |
| 61 | − if PersonalAccessToken.mobile?(MapSet.to_list(selected)) do | |
| 83 | + if PersonalAccessToken.covers?(MapSet.to_list(selected), bundle) do | |
| 62 | 84 | Enum.reduce(bundle, selected, &MapSet.delete(&2, &1)) |
| 63 | 85 | else |
| 64 | 86 | Enum.reduce(bundle, selected, &MapSet.put(&2, &1)) |
@@ -99,6 +121,10 @@ defmodule GitGudWeb.PersonalAccessTokenLive.Index do
| 99 | 121 | {:noreply, socket |> load() |> assign(:new_token, nil) |> put_flash(:info, "Token revoked.")} |
| 100 | 122 | end |
| 101 | 123 | |
| 124 | + # Name a small bundle's scopes outright; a long one just gets a count. | |
| 125 | + defp ticks_label(scopes) when length(scopes) <= 3, do: "Ticks #{Enum.join(scopes, ", ")}." | |
| 126 | + defp ticks_label(scopes), do: "Ticks the #{length(scopes)} scopes below." | |
| 127 | + | |
| 102 | 128 | defp toggle(set, value) do |
| 103 | 129 | if MapSet.member?(set, value), do: MapSet.delete(set, value), else: MapSet.put(set, value) |
| 104 | 130 | end |
@@ -171,6 +197,15 @@ defmodule GitGudWeb.PersonalAccessTokenLive.Index do
| 171 | 197 | </div> |
| 172 | 198 | </div> |
| 173 | 199 | |
| 200 | + <p | |
| 201 | + :if={PersonalAccessToken.desktop?(@new_token.scopes)} | |
| 202 | + class="text-xs opacity-80 pt-2 border-t border-success/30" | |
| 203 | + > | |
| 204 | + <span class="font-semibold">GitGud CI widget:</span> | |
| 205 | + open its settings, set the URL to <code class="select-all">{@api_base}</code>, paste the | |
| 206 | + token above and press <strong>Test Connection</strong>. | |
| 207 | + </p> | |
| 208 | + | |
| 174 | 209 | <details class="text-xs"> |
| 175 | 210 | <summary class="cursor-pointer opacity-80">Create a repo under an org</summary> |
| 176 | 211 | <pre class="bg-black text-green-300 p-2 rounded overflow-auto select-all mt-1">{curl_example(assigns)}</pre> |
@@ -188,20 +223,21 @@ defmodule GitGudWeb.PersonalAccessTokenLive.Index do
| 188 | 223 | </label> |
| 189 | 224 | |
| 190 | 225 | <div class="space-y-2"> |
| 191 | − <label class="flex items-start gap-2 cursor-pointer rounded border border-primary/40 bg-primary/5 p-2"> | |
| 226 | + <label | |
| 227 | + :for={b <- @bundles} | |
| 228 | + class="flex items-start gap-2 cursor-pointer rounded border border-primary/40 bg-primary/5 p-2" | |
| 229 | + > | |
| 192 | 230 | <input |
| 193 | 231 | type="checkbox" |
| 194 | − checked={PersonalAccessToken.mobile?(MapSet.to_list(@selected_scopes))} | |
| 195 | − phx-click="toggle_mobile" | |
| 232 | + checked={PersonalAccessToken.covers?(MapSet.to_list(@selected_scopes), b.scopes)} | |
| 233 | + phx-click="toggle_bundle" | |
| 234 | + phx-value-bundle={b.key} | |
| 196 | 235 | class="checkbox checkbox-sm checkbox-primary mt-0.5" |
| 197 | 236 | /> |
| 198 | 237 | <span class="text-sm"> |
| 199 | − <span class="font-semibold">Mobile app</span> | |
| 238 | + <span class="font-semibold">{b.label}</span> | |
| 200 | 239 | <span class="block text-xs opacity-70"> |
| 201 | − Everything the GitGud Android client needs — browse repos, watch and re-run CI, | |
| 202 | − read and comment on issues and pull requests, read wikis. Ticks the {length( | |
| 203 | − @mobile_scopes | |
| 204 | − )} scopes below. | |
| 240 | + {b.blurb} {ticks_label(b.scopes)} | |
| 205 | 241 | </span> |
| 206 | 242 | </span> |
| 207 | 243 | </label> |
@@ -245,6 +281,16 @@ defmodule GitGudWeb.PersonalAccessTokenLive.Index do
| 245 | 281 | > |
| 246 | 282 | mobile |
| 247 | 283 | </span> |
| 284 | + <%!-- Mobile covers the desktop bundle; one badge is enough. --%> | |
| 285 | + <span | |
| 286 | + :if={ | |
| 287 | + PersonalAccessToken.desktop?(t.scopes) and | |
| 288 | + not PersonalAccessToken.mobile?(t.scopes) | |
| 289 | + } | |
| 290 | + class="badge badge-xs badge-secondary ml-1" | |
| 291 | + > | |
| 292 | + desktop | |
| 293 | + </span> | |
| 248 | 294 | </td> |
| 249 | 295 | <td class="font-mono text-xs">{Enum.join(t.scopes, ", ")}</td> |
| 250 | 296 | <td class="text-xs opacity-70"> |
modified
test/git_gud_web/live/api_token_scopes_test.exs
+38
−4
@@ -1,7 +1,7 @@
| 1 | 1 | defmodule GitGudWeb.ApiTokenScopesTest do |
| 2 | 2 | @moduledoc """ |
| 3 | 3 | The API tokens page, with the per-surface scopes and the "Mobile app" |
| 4 | − bundle toggle. | |
| 4 | + and "Desktop widget" bundle toggles. | |
| 5 | 5 | |
| 6 | 6 | The bundle is the part worth testing: it's not a scope, it's a |
| 7 | 7 | checkbox that ticks a set of them, and the pairing QR only appears |
@@ -35,7 +35,7 @@ defmodule GitGudWeb.ApiTokenScopesTest do
| 35 | 35 | test "the mobile toggle ticks and unticks the whole bundle", %{conn: conn, user: user} do |
| 36 | 36 | {:ok, lv, _html} = live(conn, ~p"/users/settings/tokens") |
| 37 | 37 | |
| 38 | − lv |> element("input[phx-click=toggle_mobile]") |> render_click() | |
| 38 | + lv |> element(~s{input[phx-value-bundle="mobile"]}) |> render_click() | |
| 39 | 39 | lv |> form("form[phx-submit=create_token]", %{"name" => "phone"}) |> render_submit() |
| 40 | 40 | |
| 41 | 41 | token = Enum.find(Accounts.list_personal_access_tokens(user), &(&1.name == "phone")) |
@@ -43,7 +43,7 @@ defmodule GitGudWeb.ApiTokenScopesTest do
| 43 | 43 | |
| 44 | 44 | # Untoggling clears the bundle but leaves the default `repo` scope, |
| 45 | 45 | # which the user picked separately. |
| 46 | − lv |> element("input[phx-click=toggle_mobile]") |> render_click() | |
| 46 | + lv |> element(~s{input[phx-value-bundle="mobile"]}) |> render_click() | |
| 47 | 47 | lv |> form("form[phx-submit=create_token]", %{"name" => "script"}) |> render_submit() |
| 48 | 48 | |
| 49 | 49 | script = Enum.find(Accounts.list_personal_access_tokens(user), &(&1.name == "script")) |
@@ -56,7 +56,7 @@ defmodule GitGudWeb.ApiTokenScopesTest do
| 56 | 56 | plain = lv |> form("form[phx-submit=create_token]", %{"name" => "script"}) |> render_submit() |
| 57 | 57 | refute plain =~ "gitgud://pair" |
| 58 | 58 | |
| 59 | − lv |> element("input[phx-click=toggle_mobile]") |> render_click() | |
| 59 | + lv |> element(~s{input[phx-value-bundle="mobile"]}) |> render_click() | |
| 60 | 60 | mobile = lv |> form("form[phx-submit=create_token]", %{"name" => "phone"}) |> render_submit() |
| 61 | 61 | |
| 62 | 62 | assert mobile =~ "Pair a device" |
@@ -65,6 +65,40 @@ defmodule GitGudWeb.ApiTokenScopesTest do
| 65 | 65 | assert mobile =~ "<svg" |
| 66 | 66 | end |
| 67 | 67 | |
| 68 | + test "the desktop toggle ticks exactly the widget's scopes", %{conn: conn, user: user} do | |
| 69 | + {:ok, lv, _html} = live(conn, ~p"/users/settings/tokens") | |
| 70 | + | |
| 71 | + # Drop the default `repo` so the token carries the bundle and nothing else. | |
| 72 | + lv |> element(~s{input[phx-value-scope="repo"]}) |> render_click() | |
| 73 | + lv |> element(~s{input[phx-value-bundle="desktop"]}) |> render_click() | |
| 74 | + html = lv |> form("form[phx-submit=create_token]", %{"name" => "panel"}) |> render_submit() | |
| 75 | + | |
| 76 | + token = Enum.find(Accounts.list_personal_access_tokens(user), &(&1.name == "panel")) | |
| 77 | + assert Enum.sort(token.scopes) == Enum.sort(PersonalAccessToken.desktop_scopes()) | |
| 78 | + assert PersonalAccessToken.desktop?(token.scopes) | |
| 79 | + refute PersonalAccessToken.mobile?(token.scopes) | |
| 80 | + | |
| 81 | + # The widget hint shows; the phone pairing QR doesn't. | |
| 82 | + assert html =~ "GitGud CI widget" | |
| 83 | + refute html =~ "gitgud://pair" | |
| 84 | + end | |
| 85 | + | |
| 86 | + test "unticking desktop keeps scopes picked separately", %{conn: conn, user: user} do | |
| 87 | + {:ok, lv, _html} = live(conn, ~p"/users/settings/tokens") | |
| 88 | + | |
| 89 | + lv |> element(~s{input[phx-value-bundle="desktop"]}) |> render_click() | |
| 90 | + lv |> element(~s{input[phx-value-scope="issues:read"]}) |> render_click() | |
| 91 | + lv |> element(~s{input[phx-value-bundle="desktop"]}) |> render_click() | |
| 92 | + lv |> form("form[phx-submit=create_token]", %{"name" => "mixed"}) |> render_submit() | |
| 93 | + | |
| 94 | + token = Enum.find(Accounts.list_personal_access_tokens(user), &(&1.name == "mixed")) | |
| 95 | + assert Enum.sort(token.scopes) == ["issues:read", "repo"] | |
| 96 | + end | |
| 97 | + | |
| 98 | + test "a mobile token also covers the desktop bundle" do | |
| 99 | + assert PersonalAccessToken.desktop?(PersonalAccessToken.mobile_scopes()) | |
| 100 | + end | |
| 101 | + | |
| 68 | 102 | test "an individual scope can be toggled on its own", %{conn: conn, user: user} do |
| 69 | 103 | {:ok, lv, _html} = live(conn, ~p"/users/settings/tokens") |
| 70 | 104 |
Parents: 91c020b