Add a "Desktop widget" preset to the API tokens page

e7443bd · Gabriel Morell · 2026-09-26 17:24

14 files +130 -26
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