Deployment and lifecycle management for Nix
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: replace access token permission dropdowns with checkboxes, fix dark mode

Replace the dynamic add/remove dropdown pattern for permissions with
simple checkboxes. Fix modal and form dark mode backgrounds (zinc-300
was too bright, now zinc-800) and text color.

SOW-31

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+93 -34
+15 -3
apps/sower/lib/sower/accounts/access_token.ex
··· 37 37 end 38 38 39 39 def changeset(access_token, attrs \\ %{}) do 40 + attrs = normalize_permissions(attrs) 41 + 40 42 access_token 41 43 |> cast(attrs, [:expires_at, :user_id, :org_id, :description, :regenerate]) 42 44 |> validate_required([:expires_at, :user_id, :org_id, :description]) ··· 44 46 |> force_expires_at_regeneration() 45 47 |> cast_embed(:permissions, 46 48 required: false, 47 - with: &changeset_permission/2, 48 - sort_param: :permissions_sort, 49 - drop_param: :permissions_drop 49 + with: &changeset_permission/2 50 50 ) 51 51 end 52 + 53 + defp normalize_permissions(%{"permissions" => [first | _] = roles} = attrs) 54 + when is_binary(first) do 55 + permissions = 56 + roles 57 + |> Enum.reject(&(&1 == "")) 58 + |> Enum.map(&%{"role" => &1}) 59 + 60 + Map.put(attrs, "permissions", permissions) 61 + end 62 + 63 + defp normalize_permissions(attrs), do: attrs 52 64 53 65 def changeset_permission(permission, attrs \\ %{}) do 54 66 permission
+2 -2
apps/sower/lib/sower_web/components/core_components.ex
··· 70 70 phx-window-keydown={JS.exec("data-cancel", to: "##{@id}")} 71 71 phx-key="escape" 72 72 phx-click-away={JS.exec("data-cancel", to: "##{@id}")} 73 - class="shadow-zinc-700/10 ring-zinc-700/10 relative hidden rounded-2xl bg-zinc-200 dark:bg-zinc-300 p-14 shadow-lg ring-1 transition" 73 + class="shadow-zinc-700/10 ring-zinc-700/10 relative hidden rounded-2xl bg-zinc-200 dark:bg-zinc-800 p-14 shadow-lg ring-1 transition" 74 74 > 75 75 <div class="absolute top-6 right-5"> 76 76 <.button ··· 168 168 def simple_form(assigns) do 169 169 ~H""" 170 170 <.form :let={f} for={@for} as={@as} {@rest}> 171 - <div class="mt-10 space-y-8 bg-zinc-200 dark:bg-zinc-300 text-zinc-900 dark:text-zinc-900"> 171 + <div class="mt-10 space-y-8 bg-zinc-200 dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100"> 172 172 {render_slot(@inner_block, f)} 173 173 <div :for={action <- @actions} class="mt-2 flex items-center justify-between gap-6"> 174 174 {render_slot(action, f)}
+21 -27
apps/sower/lib/sower_web/live/settings/access_token_live/form_component.ex
··· 33 33 <.header> 34 34 Permissions 35 35 </.header> 36 - <.inputs_for :let={perm} field={@form[:permissions]}> 37 - <input type="hidden" name="access_token[permissions_sort][]" value={perm.index} /> 38 - <.input 39 - field={perm[:role]} 40 - type="select" 41 - options={Sower.Accounts.AccessToken.permission_roles()} 42 - /> 43 - <.button 44 - variant={:icon} 45 - type="button" 46 - name="access_token[permissions_drop][]" 47 - value={perm.index} 48 - phx-click={JS.dispatch("change")} 36 + <input type="hidden" name="access_token[permissions][]" value="" /> 37 + <div class="space-y-2"> 38 + <label 39 + :for={role <- Sower.Accounts.AccessToken.permission_roles()} 40 + class="flex items-center gap-2" 49 41 > 50 - <.icon name="hero-x-mark" class="w-6 h-6 relative top-2" /> 51 - </.button> 52 - </.inputs_for> 53 - 54 - <input type="hidden" name="access_token[permissions_drop][]" /> 42 + <input 43 + type="checkbox" 44 + name="access_token[permissions][]" 45 + value={role} 46 + checked={role in current_permissions(@form)} 47 + class="rounded border-zinc-300 dark:border-zinc-700" 48 + /> 49 + <span>{role}</span> 50 + </label> 51 + </div> 55 52 56 53 <:actions> 57 - <.button 58 - variant={:secondary} 59 - type="button" 60 - name="access_token[permissions_sort][]" 61 - value="new" 62 - phx-click={JS.dispatch("change")} 63 - > 64 - add permission 65 - </.button> 66 54 <.button phx-disable-with="Saving...">Save</.button> 67 55 </:actions> 68 56 </.simple_form> ··· 136 124 defp is_force_expires_at_regeneration(%AccessToken{} = access_token, new_expires_at) 137 125 when is_binary(new_expires_at) do 138 126 access_token.expires_at != new_expires_at |> Date.from_iso8601!() 127 + end 128 + 129 + defp current_permissions(%{source: changeset}) do 130 + changeset 131 + |> Ecto.Changeset.get_field(:permissions, []) 132 + |> Enum.map(&Atom.to_string(&1.role)) 139 133 end 140 134 end
+55 -2
apps/sower/test/sower_web/live/settings/access_token_live_test.exs
··· 5 5 import Sower.AccountsFixtures 6 6 7 7 @create_attrs %{ 8 - description: "test" 8 + description: "test", 9 + permissions: ["seed:read", "seed:write"] 9 10 } 10 11 @update_attrs %{ 11 12 description: "second", 12 - regenerate: true 13 + regenerate: true, 14 + permissions: ["nix-cache:read"] 13 15 } 14 16 @invalid_attrs %{ 15 17 description: "" ··· 76 78 |> render_click() 77 79 78 80 refute has_element?(index_live, "#access_tokens-#{access_token.id}") 81 + end 82 + end 83 + 84 + describe "Permissions changeset" do 85 + setup [:register_and_log_in_user] 86 + 87 + test "creates token with permissions from flat list", %{user: user} do 88 + {:ok, token} = 89 + Sower.Accounts.AccessToken.create(%{ 90 + "description" => "test", 91 + "user_id" => user.id, 92 + "org_id" => user.org_id, 93 + "permissions" => ["seed:read", "garden:register"] 94 + }) 95 + 96 + roles = Enum.map(token.permissions, & &1.role) 97 + assert :"seed:read" in roles 98 + assert :"garden:register" in roles 99 + assert length(roles) == 2 100 + end 101 + 102 + test "creates token with empty permissions", %{user: user} do 103 + {:ok, token} = 104 + Sower.Accounts.AccessToken.create(%{ 105 + "description" => "test", 106 + "user_id" => user.id, 107 + "org_id" => user.org_id, 108 + "permissions" => [""] 109 + }) 110 + 111 + assert token.permissions == [] 112 + end 113 + 114 + test "updates token permissions", %{user: user} do 115 + {:ok, token} = 116 + Sower.Accounts.AccessToken.create(%{ 117 + "description" => "test", 118 + "user_id" => user.id, 119 + "org_id" => user.org_id, 120 + "permissions" => ["seed:read"] 121 + }) 122 + 123 + {:ok, updated} = 124 + Sower.Accounts.AccessToken.update(token, %{ 125 + "permissions" => ["nix-cache:read", "agent:register"] 126 + }) 127 + 128 + roles = Enum.map(updated.permissions, & &1.role) 129 + assert :"nix-cache:read" in roles 130 + assert :"agent:register" in roles 131 + refute :"seed:read" in roles 79 132 end 80 133 end 81 134