6.0 KiB · text 5af55d9
defmodule GitGudWeb.SecretsLive.Org do
@moduledoc """
Org-scoped secrets + variables. Mirror of `SecretsLive.Repo` — all
the backend work was already shipped in slice 16; this is the UI.
Secrets are AES-256-GCM at rest, never re-rendered once saved.
Variables are plaintext. Both are merged into the runner's per-job
context (org first, repo overrides on name collision).
"""
use GitGudWeb, :live_view
alias GitGud.Organizations
alias GitGud.Secrets
@impl true
def mount(%{"handle" => handle}, _session, socket) do
org = Organizations.get_organization_by_handle!(handle)
user = socket.assigns.current_scope && socket.assigns.current_scope.user
cond do
is_nil(user) ->
{:ok,
socket
|> put_flash(:error, "Sign in to manage org secrets.")
|> redirect(to: ~p"/users/log-in")}
not Organizations.org_admin?(org, user) ->
{:ok,
socket
|> put_flash(:error, "Org admin only.")
|> redirect(to: ~p"/orgs/#{handle}")}
true ->
{:ok,
socket
|> assign(:org, org)
|> load()
|> assign(:page_title, "Secrets — #{handle}")}
end
end
defp load(socket) do
socket
|> assign(:secret_names, Secrets.list_org_secret_names(socket.assigns.org))
|> assign(:variables, Secrets.list_org_variables(socket.assigns.org))
end
@impl true
def handle_event("put_secret", %{"name" => name, "value" => value}, socket)
when value != "" do
case Secrets.put_org_secret(socket.assigns.org, name, value) do
{:ok, _} -> {:noreply, socket |> load() |> put_flash(:info, "Secret saved.")}
{:error, cs} -> {:noreply, put_flash(socket, :error, error_message(cs))}
end
end
def handle_event("put_secret", _, socket),
do: {:noreply, put_flash(socket, :error, "Name and value required.")}
def handle_event("delete_secret", %{"name" => name}, socket) do
:ok = Secrets.delete_org_secret(socket.assigns.org, name)
{:noreply, socket |> load() |> put_flash(:info, "Removed.")}
end
def handle_event("put_var", %{"name" => name, "value" => value}, socket) do
case Secrets.put_org_variable(socket.assigns.org, name, value) do
{:ok, _} -> {:noreply, socket |> load() |> put_flash(:info, "Variable saved.")}
{:error, cs} -> {:noreply, put_flash(socket, :error, error_message(cs))}
end
end
def handle_event("delete_var", %{"name" => name}, socket) do
:ok = Secrets.delete_org_variable(socket.assigns.org, name)
{:noreply, socket |> load()}
end
defp error_message(%Ecto.Changeset{} = cs) do
cs
|> Ecto.Changeset.traverse_errors(fn {m, _} -> m end)
|> inspect()
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<div class="space-y-8 max-w-2xl">
<.org_settings_header org={@org} current={:secrets} />
<section>
<h2 class="font-semibold mb-2">Secrets</h2>
<p class="text-xs opacity-70 mb-3">
Encrypted at rest. Values cannot be read back once saved —
only re-set. Available to runners as <code phx-no-curly-interpolation>${{ secrets.NAME }}</code>.
Repo secrets with the same name override these.
</p>
<form phx-submit="put_secret" class="flex gap-2 mb-3 items-end">
<input
name="name"
placeholder="MY_SECRET"
class="input input-sm input-bordered"
required
autocomplete="off"
/>
<input
name="value"
type="password"
placeholder="value"
class="input input-sm input-bordered flex-1"
required
autocomplete="off"
/>
<button type="submit" class="btn btn-sm btn-primary">Save</button>
</form>
<ul class="divide-y divide-base-300">
<li :for={n <- @secret_names} class="flex items-center gap-2 py-2 font-mono text-sm">
<span class="flex-1">{n}</span>
<button
phx-click="delete_secret"
phx-value-name={n}
data-confirm="Delete this secret?"
class="btn btn-xs btn-ghost"
>
<.icon name="hero-trash" class="size-3" />
</button>
</li>
<li :if={@secret_names == []} class="text-sm opacity-60 py-3">
No secrets set.
</li>
</ul>
</section>
<section>
<h2 class="font-semibold mb-2">Variables</h2>
<p class="text-xs opacity-70 mb-3">
Plaintext, available as <code phx-no-curly-interpolation>${{ vars.NAME }}</code>.
Repo variables with the same name override these.
</p>
<form phx-submit="put_var" class="flex gap-2 mb-3 items-end">
<input
name="name"
placeholder="MY_VAR"
class="input input-sm input-bordered"
required
autocomplete="off"
/>
<input name="value" placeholder="value" class="input input-sm input-bordered flex-1" />
<button type="submit" class="btn btn-sm btn-primary">Save</button>
</form>
<ul class="divide-y divide-base-300">
<li :for={v <- @variables} class="flex items-center gap-2 py-2 font-mono text-sm">
<span class="flex-1">{v.name}</span>
<span class="opacity-60 truncate max-w-xs">{v.value}</span>
<button
phx-click="delete_var"
phx-value-name={v.name}
data-confirm="Delete this variable?"
class="btn btn-xs btn-ghost"
>
<.icon name="hero-trash" class="size-3" />
</button>
</li>
<li :if={@variables == []} class="text-sm opacity-60 py-3">
No variables set.
</li>
</ul>
</section>
</div>
</Layouts.app>
"""
end
end