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"}
<.settings_tab
:for={section <- sections()}
id={"settings-tab-#{section.id}"}
active={@section == section.id}
patch={settings_path(section.slug)}
icon={section.icon}
label={section.label}
badge={section_badge(section.id, assigns)}
badge_class={section_badge_class(section.id, assigns)}
/>
<.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
{scope}
{credential_scope_label(scope)}
<.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
Close
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