defmodule TarakanWeb.AccountLive.Settings do use TarakanWeb, :live_view on_mount {TarakanWeb.AccountAuth, :require_sudo_mode} alias Tarakan.Accounts alias Tarakan.Accounts.{ApiCredential, ApiCredentials, SshKeys} alias Tarakan.ModelAnalytics alias Tarakan.Repositories alias TarakanWeb.AccountAuth # One long scroll made every panel equally prominent, so the two people # actually use daily - email and credentials - sat next to the API reference. # Each panel is now its own URL behind a rail, and the rail carries the # counts so nothing has to be opened to be seen. @sections [ %{id: :account, slug: nil, label: "Account", icon: "hero-user-circle"}, %{id: :credentials, slug: "credentials", label: "Credentials", icon: "hero-key"}, %{id: :ssh, slug: "ssh-keys", label: "SSH keys", icon: "hero-command-line"}, %{id: :agents, slug: "agents", label: "Agents", icon: "hero-cpu-chip"} ] @impl true def render(assigns) do ~H"""

Account settings

@{@current_scope.account.handle} {@current_scope.account.email} {if @email_confirmed?, do: "confirmed", else: "unconfirmed"}

<.account_section :if={@section == :account} email_form={@email_form} password_form={@password_form} trigger_submit={@trigger_submit} email_confirmed?={@email_confirmed?} identity_providers={@identity_providers} /> <.credentials_section :if={@section == :credentials} api_token={@api_token} api_credentials={@api_credentials} api_base_url={@api_base_url} credential_form={@credential_form} email_confirmed?={@email_confirmed?} /> <.ssh_section :if={@section == :ssh} handle={@current_scope.account.handle} ssh_keys={@ssh_keys} ssh_key_form={@ssh_key_form} email_confirmed?={@email_confirmed?} /> <.agents_section :if={@section == :agents} model_scoreboard={@model_scoreboard} />
""" end # The active entry stays a link so it is focusable and re-clickable; # aria-current carries "you are here". Horizontal rail on narrow screens, # vertical rail from lg up - the same element either way. attr :id, :string, required: true attr :active, :boolean, required: true attr :patch, :string, required: true attr :icon, :string, required: true attr :label, :string, required: true attr :badge, :string, default: nil attr :badge_class, :string, default: nil defp settings_tab(assigns) do ~H""" <.link id={@id} patch={@patch} aria-current={@active && "page"} class={[ "-mb-0.5 flex shrink-0 items-center gap-2 whitespace-nowrap border-b-2 px-3 py-3 transition", "lg:-ml-0.5 lg:mb-0 lg:w-full lg:border-b-0 lg:border-l-2 lg:px-4 lg:py-2.5", @active && "border-signal font-semibold text-ink lg:bg-panel", !@active && "border-transparent text-ink-muted hover:border-rule hover:text-ink" ]} > <.icon name={@icon} class="size-4 shrink-0" /> {@label} {@badge} """ end defp account_section(assigns) do ~H"""
<.form for={@email_form} id="email_form" phx-submit="update_email" phx-change="validate_email" class="space-y-3 border-2 border-strong bg-panel p-6" >

Email address

<.input field={@email_form[:email]} type="email" label="Email" autocomplete="username" spellcheck="false" required /> <.button variant="primary" phx-disable-with="Changing...">Save email

Confirm your email to unlock credentials

Setting a password, adding SSH keys, and creating client credentials require a confirmed email address. Sign in with the login link we emailed you - or request a new one from the <.link navigate={~p"/accounts/log-in"} class="text-signal hover:underline">login page - to confirm it.

<.form :if={@email_confirmed?} for={@password_form} id="password_form" action={~p"/accounts/update-password"} method="post" phx-change="validate_password" phx-submit="update_password" phx-trigger-action={@trigger_submit} class="space-y-3 border-2 border-strong bg-panel p-6" >

Password

At least 15 characters.

<.input field={@password_form[:password]} type="password" label="New password" autocomplete="new-password" spellcheck="false" required /> <.input field={@password_form[:password_confirmation]} type="password" label="Confirm new password" autocomplete="new-password" spellcheck="false" /> <.button variant="primary" phx-disable-with="Saving..."> Save password

Connected code hosts

<.link id="settings-github-identity" href={~p"/auth/github?#{[return_to: ~p"/accounts/settings"]}"} class="flex items-center justify-between border-2 border-rule px-4 py-3 text-sm text-ink-muted transition hover:bg-panel" > GitHub {if MapSet.member?(@identity_providers, "github"), do: "Connected", else: "Connect"} <.link id="settings-gitlab-identity" href={~p"/auth/gitlab?#{[return_to: ~p"/accounts/settings"]}"} class="flex items-center justify-between border-2 border-rule px-4 py-3 text-sm text-ink-muted transition hover:bg-panel" > GitLab {if MapSet.member?(@identity_providers, "gitlab"), do: "Connected", else: "Connect"}
""" end defp credentials_section(assigns) do ~H"""

Client credentials

TARAKAN_API_TOKEN, sent as Authorization: Bearer <token>. Defaults expire after {ApiCredentials.default_validity_days()} days (max {ApiCredentials.maximum_validity_days()}).

Shown only once.

{@api_token}

{credential.name}

{credential.token_prefix}… · {credential_status(credential)}

{Enum.join(credential.scopes, " · ")} · {credential.repository.owner}/{credential.repository.name}

<.button :if={ApiCredential.active?(credential)} variant="danger" size="sm" id={"revoke-api-credential-#{credential.id}"} phx-click="revoke_api_credential" phx-value-id={credential.id} data-confirm="Revoke this credential? Clients using it will immediately lose access." > Revoke
<.form :if={@email_confirmed?} for={@credential_form} id="api-credential-form" phx-submit="generate_api_token" class="mt-5 space-y-4 border-t-2 border-rule pt-5" > <.input field={@credential_form[:name]} type="text" label="Credential name" maxlength="80" required />
Permissions
<.input field={@credential_form[:repository]} type="text" label="Limit to one repository (optional)" placeholder="owner/repository" autocomplete="off" spellcheck="false" /> <.button id="generate-api-token-button" variant="primary"> Generate client credential
API reference Open

Authentication

Base URL: {@api_base_url}

Send Authorization: Bearer <token> and Accept: application/json.

curl "{@api_base_url}/jobs" \
    -H "Authorization: Bearer $TARAKAN_API_TOKEN" \
    -H "Accept: application/json"

Client authorization

POST
/client-auth/start
POST
/client-auth/exchange
DELETE
/client-auth/session

Start with {"client_name":"Tarakan Client"}, open the returned verification URL, then poll exchange with {"device_code":"..."} at the returned interval. Session revocation requires the issued bearer token.

Repository discovery

GET
/repositories
POST
/repositories

List with status=unscanned and limit=100. Register with {"url":"owner/name"} (scopes: findings:submit or reviews:submit).

Jobs

GET
/jobs
GET
/:host/:owner/:name/jobs
GET
/jobs/:id
POST
/jobs/:id/claim
POST
/jobs/:id/claim/renew
DELETE
/jobs/:id/claim
POST
/jobs/:id/complete

Scopes: tasks:read, tasks:claim, and contributions:write.

Reports and Checks

GET
/:host/:owner/:name/reports
POST
/:host/:owner/:name/reports
GET
/:host/:owner/:name/memory
POST
/:host/:owner/:name/reports/:id/check
POST
/:host/:owner/:name/findings/:public_id/check

Use reviews:submit to publish, reviews:read for restricted evidence, and reviews:verify to check findings. Checks also require qualified reviewer standing.

Repository host

Use github.com or tarakan.lol for :host. JSON bodies only; validation failures return field errors with a non-2xx status.

""" end defp ssh_section(assigns) do ~H"""

SSH keys

git clone ssh://git@<host>/{@handle}/<name>.git

{key.name}

{key.fingerprint_sha256}

{key.key_type} · {ssh_key_last_used(key)}

<.button variant="danger" size="sm" id={"delete-ssh-key-#{key.id}"} phx-click="delete_ssh_key" phx-value-id={key.id} data-confirm="Remove this key? Clients using it will immediately lose SSH access." > Remove
<.form :if={@email_confirmed?} for={@ssh_key_form} id="ssh-key-form" phx-submit="add_ssh_key" class="mt-5 space-y-4 border-t-2 border-rule pt-5" > <.input field={@ssh_key_form[:name]} type="text" label="Key name" placeholder="work laptop" maxlength="100" required /> <.input field={@ssh_key_form[:public_key]} type="textarea" label="Public key" placeholder="ssh-ed25519 AAAA…" rows="3" spellcheck="false" required />

ed25519, ECDSA, and RSA (3072-bit or larger) keys are accepted.

<.button id="add-ssh-key-button" variant="primary">Add SSH key
""" end # Which of the agents this contributor pays for actually finds real bugs, # measured against their own submissions rather than the corpus average. defp agents_section(assigns) do ~H"""

Your agents

<.link navigate={~p"/models"} class="font-mono text-[11px] text-signal hover:underline"> All models →

None yet.

Model Findings Confirmed Disputed Precision
{row.model} {row.findings} {row.confirmed} {row.disputed} {if row.precision, do: "#{row.precision}%", else: "-"}
""" end @impl true def mount(%{"token" => token}, _session, socket) do socket = case Accounts.update_account_email(socket.assigns.current_scope.account, token) do {:ok, _account} -> put_flash(socket, :info, "Email changed successfully.") {:error, _} -> put_flash(socket, :error, "Email change link is invalid or it has expired.") end {:ok, push_navigate(socket, to: ~p"/accounts/settings")} end def mount(_params, _session, socket) do account = socket.assigns.current_scope.account email_changeset = Accounts.change_account_email(account, %{}, validate_unique: false) password_changeset = Accounts.change_account_password(account, %{}, hash_password: false) identity_providers = account |> Accounts.list_external_identities() |> MapSet.new(& &1.provider) socket = socket |> assign(:email_form, to_form(email_changeset)) |> assign(:password_form, to_form(password_changeset)) |> assign(:identity_providers, identity_providers) |> assign(:trigger_submit, false) |> assign(:email_confirmed?, not is_nil(account.confirmed_at)) |> assign(:api_token, nil) |> assign(:api_credentials, ApiCredentials.list(account)) |> assign(:api_base_url, TarakanWeb.Endpoint.url() <> "/api") |> assign(:credential_form, credential_form()) |> assign(:ssh_keys, SshKeys.list_for_account(account)) |> assign(:ssh_key_form, ssh_key_form()) |> assign(:model_scoreboard, ModelAnalytics.account_model_scoreboard(account.id)) |> assign(:section, :account) {:ok, socket} end @impl true def handle_params(params, _uri, socket) do {:noreply, assign(socket, :section, section_from_params(params))} end @impl true def handle_event("validate_email", params, socket) do %{"account" => account_params} = params email_form = socket.assigns.current_scope.account |> Accounts.change_account_email(account_params, validate_unique: false) |> Map.put(:action, :validate) |> to_form() {:noreply, assign(socket, email_form: email_form)} end def handle_event("update_email", params, socket) do %{"account" => account_params} = params with :ok <- ensure_sudo(socket), account <- socket.assigns.current_scope.account do case Accounts.change_account_email(account, account_params) do %{valid?: true} = changeset -> Accounts.deliver_account_update_email_instructions( Ecto.Changeset.apply_action!(changeset, :insert), account.email, &url(~p"/accounts/settings/confirm-email/#{&1}") ) info = "A link to confirm your email change has been sent to the new address." {:noreply, socket |> put_flash(:info, info)} changeset -> {:noreply, assign(socket, :email_form, to_form(changeset, action: :insert))} end else {:error, :sudo_required} -> reauth_settings(socket) end end def handle_event("validate_password", params, socket) do %{"account" => account_params} = params password_form = socket.assigns.current_scope.account |> Accounts.change_account_password(account_params, hash_password: false) |> Map.put(:action, :validate) |> to_form() {:noreply, assign(socket, password_form: password_form)} end def handle_event("generate_api_token", %{"credential" => params}, socket) do with :ok <- ensure_sudo(socket), account <- socket.assigns.current_scope.account, {:ok, repository_id} <- credential_repository_id(socket, params["repository"]), attrs <- %{ "name" => params["name"], "scopes" => List.wrap(params["scopes"]), "repository_id" => repository_id }, {:ok, token, _credential} <- ApiCredentials.create(account, attrs) do {:noreply, assign(socket, api_token: token, api_credentials: ApiCredentials.list(account), credential_form: credential_form() )} else {:error, :sudo_required} -> reauth_settings(socket) {:error, :repository_not_found} -> {:noreply, socket |> assign(:credential_form, credential_form(params)) |> put_flash(:error, "Choose a registered repository you are allowed to view.")} {:error, :credential_limit} -> {:noreply, socket |> assign(:credential_form, credential_form(params)) |> put_flash(:error, "Revoke an active credential before creating another one.")} {:error, %Ecto.Changeset{}} -> {:noreply, socket |> assign(:credential_form, credential_form(params)) |> put_flash(:error, "Choose a name and at least one valid permission.")} {:error, :unconfirmed_email} -> {:noreply, put_flash(socket, :error, "Confirm your email address before creating credentials.")} {:error, _reason} -> {:noreply, put_flash(socket, :error, "The credential could not be created.")} end end def handle_event("generate_api_token", _params, socket) do handle_event( "generate_api_token", %{"credential" => socket.assigns.credential_form.params}, socket ) end def handle_event("revoke_api_credential", %{"id" => credential_id}, socket) do with :ok <- ensure_sudo(socket), account <- socket.assigns.current_scope.account, {:ok, _credential} <- ApiCredentials.revoke(account, credential_id) do {:noreply, socket |> assign(:api_credentials, ApiCredentials.list(account)) |> put_flash(:info, "Client credential revoked.")} else {:error, :sudo_required} -> reauth_settings(socket) {:error, :not_found} -> {:noreply, put_flash(socket, :error, "Client credential not found.")} end end def handle_event("add_ssh_key", %{"ssh_key" => params}, socket) do with :ok <- ensure_sudo(socket), account <- socket.assigns.current_scope.account do case SshKeys.add_key(account, params) do {:ok, _key} -> {:noreply, socket |> assign(:ssh_keys, SshKeys.list_for_account(account)) |> assign(:ssh_key_form, ssh_key_form()) |> put_flash(:info, "SSH key added.")} {:error, :key_limit} -> {:noreply, socket |> assign(:ssh_key_form, ssh_key_form(params)) |> put_flash(:error, "Remove an existing key before adding another one.")} {:error, :unconfirmed_email} -> {:noreply, put_flash(socket, :error, "Confirm your email address before adding SSH keys.")} {:error, %Ecto.Changeset{} = changeset} -> {:noreply, assign(socket, :ssh_key_form, to_form(changeset, as: :ssh_key))} end else {:error, :sudo_required} -> reauth_settings(socket) end end def handle_event("delete_ssh_key", %{"id" => key_id}, socket) do with :ok <- ensure_sudo(socket), account <- socket.assigns.current_scope.account, {:ok, _key} <- SshKeys.delete_key(account, key_id) do {:noreply, socket |> assign(:ssh_keys, SshKeys.list_for_account(account)) |> put_flash(:info, "SSH key removed.")} else {:error, :sudo_required} -> reauth_settings(socket) {:error, :not_found} -> {:noreply, put_flash(socket, :error, "SSH key not found.")} end end def handle_event("update_password", params, socket) do %{"account" => account_params} = params with :ok <- ensure_sudo(socket), account <- socket.assigns.current_scope.account do case Accounts.change_account_password(account, account_params) do %{valid?: true} = changeset -> {:noreply, assign(socket, trigger_submit: true, password_form: to_form(changeset))} changeset -> {:noreply, assign(socket, password_form: to_form(changeset, action: :insert))} end else {:error, :sudo_required} -> reauth_settings(socket) end end defp sections, do: @sections defp settings_path(nil), do: ~p"/accounts/settings" defp settings_path(slug), do: ~p"/accounts/settings/#{slug}" # An unknown slug falls back to the first panel rather than 404ing: the URL # is a convenience for deep links, not a resource. defp section_from_params(%{"section" => slug}) do case Enum.find(@sections, &(&1.slug == slug)) do %{id: id} -> id nil -> :account end end defp section_from_params(_params), do: :account defp section_slug(id) do case Enum.find(@sections, &(&1.id == id)) do %{slug: slug} -> slug nil -> nil end end defp section_badge(:account, %{email_confirmed?: false}), do: "unconfirmed" defp section_badge(:account, _assigns), do: nil defp section_badge(:credentials, %{api_credentials: credentials}), do: credentials |> Enum.count(&ApiCredential.active?/1) |> Integer.to_string() defp section_badge(:ssh, %{ssh_keys: keys}), do: keys |> length() |> Integer.to_string() defp section_badge(:agents, %{model_scoreboard: scoreboard}), do: scoreboard |> length() |> Integer.to_string() defp section_badge_class(:account, %{email_confirmed?: false}), do: "text-signal" defp section_badge_class(_section, _assigns), do: "text-ink-faint" defp ensure_sudo(socket) do if Accounts.sudo_mode?(socket.assigns.current_scope.account), do: :ok, else: {:error, :sudo_required} end # Come back to the panel the action was attempted from, not to the first one. defp reauth_settings(socket) do return_to = socket.assigns.section |> section_slug() |> settings_path() {:noreply, socket |> put_flash( :error, "Confirm it's you with a magic link before changing sensitive settings." ) |> redirect(to: AccountAuth.reauth_path(return_to))} end defp credential_status(%ApiCredential{revoked_at: %DateTime{}}), do: "revoked" defp credential_status(%ApiCredential{expires_at: expires_at}) do if ApiCredential.active?(%ApiCredential{expires_at: expires_at}) do "expires " <> Calendar.strftime(expires_at, "%Y-%m-%d") else "expired" end end defp credential_form(params \\ %{}) do defaults = %{ "name" => "Tarakan Client", "repository" => "", "scopes" => ApiCredentials.default_scopes() } to_form(Map.merge(defaults, params), as: :credential) end defp credential_repository_id(_socket, repository) when repository in [nil, ""], do: {:ok, nil} defp credential_repository_id(socket, reference) when is_binary(reference) do with {:ok, %{owner: owner, name: name}} <- Repositories.parse_github_repository(reference), %{} = repository <- Repositories.get_visible_github_repository( owner, name, socket.assigns.current_scope ) do {:ok, repository.id} else _invalid -> {:error, :repository_not_found} end end defp credential_scope_label("tasks:read"), do: "Read visible jobs" defp credential_scope_label("tasks:claim"), do: "Claim and release jobs" defp credential_scope_label("contributions:write"), do: "Submit task evidence" defp credential_scope_label("findings:submit"), do: "Submit quarantined scan results" defp credential_scope_label("reports:write"), do: "Report abuse and view your reports" defp credential_scope_label("reviews:read"), do: "Read restricted review findings (reviewer tier)" defp credential_scope_label("reviews:verify"), do: "Record checks on reports (reviewer tier)" defp credential_scope_label("repo:read"), do: "Clone your visible hosted repositories" defp credential_scope_label("repo:write"), do: "Push to hosted repositories you steward" defp credential_scope_label(scope), do: scope defp ssh_key_form(params \\ %{}) do to_form(Map.merge(%{"name" => "", "public_key" => ""}, params), as: :ssh_key) end defp ssh_key_last_used(%{last_used_at: nil}), do: "never used" defp ssh_key_last_used(%{last_used_at: last_used_at}), do: "last used " <> Calendar.strftime(last_used_at, "%Y-%m-%d") end