Initial commit on Forgejo
Fresh repository history for elektrine/tarakan hosted at https://git.elektrine.com/elektrine/tarakan.
This commit is contained in:
commit
af6077b9c3
455 changed files with 78366 additions and 0 deletions
87
lib/tarakan_web/live/account_live/confirmation.ex
Normal file
87
lib/tarakan_web/live/account_live/confirmation.ex
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
defmodule TarakanWeb.AccountLive.Confirmation do
|
||||
use TarakanWeb, :live_view
|
||||
|
||||
alias Tarakan.Accounts
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
|
||||
<Layouts.page width={:form}>
|
||||
<div class="text-center">
|
||||
<.header>Welcome {@account.email}</.header>
|
||||
</div>
|
||||
|
||||
<.form
|
||||
for={@form}
|
||||
id="login_form"
|
||||
phx-mounted={JS.focus_first()}
|
||||
phx-submit="submit"
|
||||
action={~p"/accounts/log-in"}
|
||||
phx-trigger-action={@trigger_submit}
|
||||
class="space-y-3"
|
||||
>
|
||||
<input type="hidden" name={@form[:token].name} value={@form[:token].value} />
|
||||
<input :if={@return_to} type="hidden" name="account[return_to]" value={@return_to} />
|
||||
<.input
|
||||
field={@form[:remember_me]}
|
||||
type="checkbox"
|
||||
label="Stay logged in on this device"
|
||||
checked
|
||||
/>
|
||||
<button
|
||||
phx-disable-with="Logging in..."
|
||||
class="clip-notch inline-flex h-11 w-full items-center justify-center bg-btn px-4 font-display text-sm uppercase tracking-[0.12em] text-btn-fg transition hover:opacity-90"
|
||||
>
|
||||
Log in
|
||||
</button>
|
||||
</.form>
|
||||
</Layouts.page>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def mount(%{"token" => token} = params, _session, socket) do
|
||||
return_to =
|
||||
case params do
|
||||
%{"return_to" => path} when is_binary(path) ->
|
||||
TarakanWeb.SafeRedirect.local_path(path, nil)
|
||||
|
||||
_ ->
|
||||
nil
|
||||
end
|
||||
|
||||
if account = Accounts.get_account_by_magic_link_token(token) do
|
||||
form = to_form(%{"token" => token}, as: "account")
|
||||
|
||||
{:ok,
|
||||
assign(socket,
|
||||
account: account,
|
||||
form: form,
|
||||
trigger_submit: false,
|
||||
return_to: return_to
|
||||
), temporary_assigns: [form: nil]}
|
||||
else
|
||||
next =
|
||||
if return_to,
|
||||
do: ~p"/accounts/log-in?#{[return_to: return_to]}",
|
||||
else: ~p"/accounts/log-in"
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> put_flash(:error, "Magic link is invalid or it has expired.")
|
||||
|> push_navigate(to: next)}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("submit", %{"account" => params}, socket) do
|
||||
params =
|
||||
if socket.assigns.return_to,
|
||||
do: Map.put(params, "return_to", socket.assigns.return_to),
|
||||
else: params
|
||||
|
||||
{:noreply, assign(socket, form: to_form(params, as: "account"), trigger_submit: true)}
|
||||
end
|
||||
end
|
||||
211
lib/tarakan_web/live/account_live/login.ex
Normal file
211
lib/tarakan_web/live/account_live/login.ex
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
defmodule TarakanWeb.AccountLive.Login do
|
||||
use TarakanWeb, :live_view
|
||||
|
||||
alias Tarakan.Accounts
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
|
||||
<Layouts.page width={:form} class={["space-y-5"]}>
|
||||
<div class="text-center">
|
||||
<.header>
|
||||
<p>Log in</p>
|
||||
<:subtitle>
|
||||
<%= if @current_scope && @current_scope.account do %>
|
||||
Confirm it's you, or connect another host.
|
||||
<% else %>
|
||||
One click. No password required.
|
||||
<% end %>
|
||||
</:subtitle>
|
||||
</.header>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:if={local_mail_adapter?()}
|
||||
class="flex gap-3 border-2 border-rule bg-panel p-4 text-sm text-ink-muted"
|
||||
>
|
||||
<.icon name="hero-information-circle" class="size-6 shrink-0" />
|
||||
<div>
|
||||
<p>You are running the local mail adapter.</p>
|
||||
<p>
|
||||
To see sent emails, visit <.link href="/dev/mailbox" class="underline">the mailbox page</.link>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<.link
|
||||
id="github-login-button"
|
||||
href={~p"/auth/github?#{[return_to: @provider_return_to]}"}
|
||||
class="clip-notch flex h-12 w-full items-center justify-center bg-btn px-4 font-display text-sm uppercase tracking-[0.12em] text-btn-fg transition hover:opacity-90"
|
||||
>
|
||||
Continue with GitHub
|
||||
</.link>
|
||||
<.link
|
||||
id="gitlab-login-button"
|
||||
href={~p"/auth/gitlab?#{[return_to: @provider_return_to]}"}
|
||||
class="flex h-12 w-full items-center justify-center border border-strong px-4 font-display text-sm uppercase tracking-[0.12em] text-ink transition hover:bg-panel"
|
||||
>
|
||||
Continue with GitLab
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 py-1 font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint">
|
||||
<span class="h-px flex-1 bg-rule"></span>email<span class="h-px flex-1 bg-rule"></span>
|
||||
</div>
|
||||
|
||||
<.form
|
||||
:let={f}
|
||||
for={@form}
|
||||
id="login_form_magic"
|
||||
action={~p"/accounts/log-in"}
|
||||
phx-submit="submit_magic"
|
||||
class="space-y-3 border-2 border-strong bg-panel p-6"
|
||||
>
|
||||
<input :if={@return_to} type="hidden" name="account[return_to]" value={@return_to} />
|
||||
<.input
|
||||
readonly={!!(@current_scope && @current_scope.account)}
|
||||
field={f[:email]}
|
||||
type="email"
|
||||
label="Email"
|
||||
autocomplete="username"
|
||||
spellcheck="false"
|
||||
required
|
||||
phx-mounted={JS.focus()}
|
||||
/>
|
||||
<button class="clip-notch inline-flex h-11 w-full items-center justify-center bg-btn px-4 font-display text-sm uppercase tracking-[0.12em] text-btn-fg transition hover:opacity-90">
|
||||
Email me a login link
|
||||
</button>
|
||||
</.form>
|
||||
|
||||
<details class="group border-2 border-rule">
|
||||
<summary class="flex cursor-pointer items-center justify-between px-4 py-3 font-mono text-[11px] uppercase tracking-[0.12em] text-ink-faint transition hover:text-ink">
|
||||
Use a password instead
|
||||
<.icon
|
||||
name="hero-chevron-down"
|
||||
class="size-4 transition-transform group-open:rotate-180"
|
||||
/>
|
||||
</summary>
|
||||
<.form
|
||||
:let={f}
|
||||
for={@form}
|
||||
id="login_form_password"
|
||||
action={~p"/accounts/log-in"}
|
||||
phx-submit="submit_password"
|
||||
phx-trigger-action={@trigger_submit}
|
||||
class="space-y-3 border-t-2 border-rule bg-panel p-6"
|
||||
>
|
||||
<input :if={@return_to} type="hidden" name="account[return_to]" value={@return_to} />
|
||||
<.input
|
||||
readonly={!!(@current_scope && @current_scope.account)}
|
||||
field={f[:identifier]}
|
||||
type="text"
|
||||
label="Handle or email"
|
||||
autocomplete="username"
|
||||
spellcheck="false"
|
||||
required
|
||||
/>
|
||||
<.input
|
||||
field={@form[:password]}
|
||||
type="password"
|
||||
label="Password"
|
||||
autocomplete="current-password"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<button class="clip-notch inline-flex h-11 w-full items-center justify-center bg-btn px-4 font-display text-sm uppercase tracking-[0.12em] text-btn-fg transition hover:opacity-90">
|
||||
Log in
|
||||
</button>
|
||||
</.form>
|
||||
</details>
|
||||
</Layouts.page>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def mount(params, session, socket) do
|
||||
account = get_in(socket.assigns, [:current_scope, Access.key(:account)])
|
||||
|
||||
email =
|
||||
Phoenix.Flash.get(socket.assigns.flash, :email) ||
|
||||
(account && account.email)
|
||||
|
||||
identifier =
|
||||
Phoenix.Flash.get(socket.assigns.flash, :identifier) ||
|
||||
(account && account.handle)
|
||||
|
||||
form = to_form(%{"email" => email, "identifier" => identifier}, as: "account")
|
||||
|
||||
return_to =
|
||||
case params["return_to"] || session["account_return_to"] do
|
||||
path when is_binary(path) -> TarakanWeb.SafeRedirect.local_path(path, nil)
|
||||
_other -> nil
|
||||
end
|
||||
|
||||
provider_return_to = return_to || if(account, do: ~p"/accounts/settings", else: ~p"/")
|
||||
|
||||
{:ok,
|
||||
assign(socket,
|
||||
form: form,
|
||||
trigger_submit: false,
|
||||
return_to: return_to,
|
||||
provider_return_to: provider_return_to
|
||||
)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("submit_password", _params, socket) do
|
||||
{:noreply, assign(socket, :trigger_submit, true)}
|
||||
end
|
||||
|
||||
def handle_event("submit_magic", %{"account" => %{"email" => email} = params}, socket) do
|
||||
email_key = :crypto.hash(:sha256, email |> String.trim() |> String.downcase())
|
||||
|
||||
allowed? =
|
||||
TarakanWeb.BrowserRateLimit.allowed?(:magic_ip, socket.assigns.client_ip) and
|
||||
TarakanWeb.BrowserRateLimit.allowed?(:magic_email, email_key)
|
||||
|
||||
return_to =
|
||||
case params["return_to"] || socket.assigns.return_to do
|
||||
path when is_binary(path) and path != "" ->
|
||||
TarakanWeb.SafeRedirect.local_path(path, nil)
|
||||
|
||||
_ ->
|
||||
nil
|
||||
end
|
||||
|
||||
if allowed? do
|
||||
account = Accounts.get_account_by_email(email)
|
||||
|
||||
# Delivery runs in the background so response time does not reveal
|
||||
# whether the email is registered.
|
||||
if account && Accounts.access_allowed?(account) do
|
||||
Accounts.deliver_login_instructions_async(account, fn token ->
|
||||
if return_to do
|
||||
url(~p"/accounts/log-in/#{token}?#{[return_to: return_to]}")
|
||||
else
|
||||
url(~p"/accounts/log-in/#{token}")
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
info =
|
||||
"If your email is in our system, you will receive instructions for logging in shortly."
|
||||
|
||||
next =
|
||||
if return_to,
|
||||
do: ~p"/accounts/log-in?#{[return_to: return_to]}",
|
||||
else: ~p"/accounts/log-in"
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, info)
|
||||
|> push_navigate(to: next)}
|
||||
end
|
||||
|
||||
defp local_mail_adapter? do
|
||||
Application.get_env(:tarakan, Tarakan.Mailer)[:adapter] == Swoosh.Adapters.Local
|
||||
end
|
||||
end
|
||||
111
lib/tarakan_web/live/account_live/profile.ex
Normal file
111
lib/tarakan_web/live/account_live/profile.ex
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
defmodule TarakanWeb.AccountLive.Profile do
|
||||
use TarakanWeb, :live_view
|
||||
|
||||
alias Tarakan.Credits
|
||||
alias Tarakan.Profiles
|
||||
alias Tarakan.Reputation
|
||||
|
||||
@impl true
|
||||
def mount(%{"handle" => handle}, _session, socket) do
|
||||
case Profiles.get_profile(handle) do
|
||||
nil ->
|
||||
raise Ecto.NoResultsError, queryable: Tarakan.Accounts.Account
|
||||
|
||||
account ->
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "@#{account.handle}")
|
||||
|> assign(:meta_description, meta_description(account))
|
||||
|> assign(:canonical_path, ~p"/#{account.handle}")
|
||||
|> assign(:account, account)
|
||||
|> assign(:reputation, Reputation.score(account))
|
||||
|> assign(:stake_summary, Reputation.stake_summary(account))
|
||||
|> assign(:credit_balance, Credits.balance(account))
|
||||
|> assign(:credit_entries, Credits.ledger(account, limit: 20))
|
||||
|> assign(:stats, Profiles.contribution_stats(account))
|
||||
|> assign(:badges, Profiles.badges(account))
|
||||
|> assign(:repositories, Profiles.list_repositories(account))
|
||||
|> assign(:reviews, Profiles.list_reviews(account))
|
||||
|> assign(:findings, Profiles.list_findings(account))
|
||||
|> assign(:checks, Profiles.list_checks(account))}
|
||||
end
|
||||
end
|
||||
|
||||
defp meta_description(account) do
|
||||
"@#{account.handle} on Tarakan - #{tier_label(account.trust_tier)} contributor to the public security record for open source."
|
||||
end
|
||||
|
||||
@doc false
|
||||
def tier_label("reviewer"), do: "Reviewer"
|
||||
def tier_label("contributor"), do: "Contributor"
|
||||
def tier_label(_new), do: "New"
|
||||
|
||||
@doc false
|
||||
def role_label("admin"), do: "Admin"
|
||||
def role_label("moderator"), do: "Moderator"
|
||||
def role_label(_member), do: nil
|
||||
|
||||
@doc false
|
||||
def badge_label(:first_blood), do: "First blood"
|
||||
def badge_label(:century), do: "Century"
|
||||
def badge_label(:eradicator), do: "Eradicator"
|
||||
def badge_label(:exorcist), do: "Exorcist"
|
||||
def badge_label(:sharpshooter), do: "Sharpshooter"
|
||||
def badge_label(badge), do: badge |> to_string() |> String.replace("_", " ")
|
||||
|
||||
@doc false
|
||||
def joined_on(%DateTime{} = datetime), do: Calendar.strftime(datetime, "%B %Y")
|
||||
def joined_on(%NaiveDateTime{} = datetime), do: Calendar.strftime(datetime, "%B %Y")
|
||||
|
||||
@doc false
|
||||
def activity_time(%DateTime{} = datetime), do: Calendar.strftime(datetime, "%Y-%m-%d")
|
||||
|
||||
@doc false
|
||||
def credit_kind_label(kind) when is_binary(kind), do: String.replace(kind, "_", " ")
|
||||
def credit_kind_label(_kind), do: "entry"
|
||||
|
||||
@doc false
|
||||
def credit_amount(amount) when is_integer(amount) and amount > 0, do: "+#{amount}"
|
||||
def credit_amount(amount) when is_integer(amount), do: Integer.to_string(amount)
|
||||
|
||||
@doc false
|
||||
def short_sha(sha) when is_binary(sha), do: String.slice(sha, 0, 7)
|
||||
def short_sha(_sha), do: nil
|
||||
|
||||
@doc false
|
||||
def review_kind_label("code_review"), do: "Code review"
|
||||
def review_kind_label("threat_model"), do: "Threat model"
|
||||
def review_kind_label("privacy_review"), do: "Privacy review"
|
||||
def review_kind_label("business_logic"), do: "Business logic"
|
||||
def review_kind_label("diff_review"), do: "Diff review"
|
||||
def review_kind_label("refute_finding"), do: "Refute a finding"
|
||||
def review_kind_label("reproduce_finding"), do: "Reproduce a finding"
|
||||
def review_kind_label("calibrate_severity"), do: "Re-score severity"
|
||||
def review_kind_label("synthesize_rule"), do: "Write a detector"
|
||||
def review_kind_label(other) when is_binary(other), do: String.replace(other, "_", " ")
|
||||
def review_kind_label(_other), do: "Report"
|
||||
|
||||
@doc false
|
||||
def review_status_label("accepted"), do: "Accepted"
|
||||
def review_status_label("quarantined"), do: "Quarantined"
|
||||
def review_status_label("rejected"), do: "Rejected"
|
||||
def review_status_label("contested"), do: "Contested"
|
||||
def review_status_label(other) when is_binary(other), do: String.capitalize(other)
|
||||
def review_status_label(_other), do: nil
|
||||
|
||||
@doc false
|
||||
def provider_label("github"), do: "GitHub"
|
||||
def provider_label("gitlab"), do: "GitLab"
|
||||
def provider_label(other), do: String.capitalize(other)
|
||||
|
||||
@doc false
|
||||
def check_path(%{public_id: public_id}) when is_binary(public_id) do
|
||||
~p"/findings/#{public_id}"
|
||||
end
|
||||
|
||||
def check_path(%{repository: repository}) when not is_nil(repository) do
|
||||
TarakanWeb.RepositoryPaths.repository_security_path(repository)
|
||||
end
|
||||
|
||||
def check_path(_entry), do: ~p"/"
|
||||
end
|
||||
354
lib/tarakan_web/live/account_live/profile.html.heex
Normal file
354
lib/tarakan_web/live/account_live/profile.html.heex
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
|
||||
<Layouts.page>
|
||||
<.breadcrumbs id="profile-breadcrumb">
|
||||
<:crumb navigate={~p"/"}>registry</:crumb>
|
||||
<:crumb>@{@account.handle}</:crumb>
|
||||
</.breadcrumbs>
|
||||
|
||||
<header id="profile-header" class="border-b-2 border-strong pb-6">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<.notch_badge
|
||||
:if={role_label(@account.platform_role)}
|
||||
id="profile-role"
|
||||
class="text-signal"
|
||||
>
|
||||
{role_label(@account.platform_role)}
|
||||
</.notch_badge>
|
||||
<.notch_badge id="profile-tier" class="text-ink-muted">
|
||||
{tier_label(@account.trust_tier)}
|
||||
</.notch_badge>
|
||||
<span :if={@badges != []} id="profile-badges" class="contents">
|
||||
<.notch_badge
|
||||
:for={badge <- @badges}
|
||||
id={"profile-badge-#{badge}"}
|
||||
class="text-quote"
|
||||
>
|
||||
{badge_label(badge)}
|
||||
</.notch_badge>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1
|
||||
id="profile-handle"
|
||||
class="mt-3 break-words font-display text-3xl font-medium uppercase leading-tight tracking-[0.02em] text-ink sm:text-4xl"
|
||||
>
|
||||
<span class="text-ink-faint">@</span>{@account.handle}
|
||||
</h1>
|
||||
<p :if={@account.display_name} class="mt-1 text-sm text-ink-muted">
|
||||
{@account.display_name}
|
||||
</p>
|
||||
|
||||
<div class="mt-4 flex flex-wrap items-center gap-x-5 gap-y-2 font-mono text-xs text-ink-faint">
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5"
|
||||
title={"#{@reputation.verification} from verified work · #{@reputation.votes} from votes"}
|
||||
>
|
||||
<.icon name="hero-bolt" class="size-3.5" />
|
||||
<span id="profile-reputation" class="tabular-nums text-ink-muted">
|
||||
{@reputation.total}
|
||||
</span>
|
||||
reputation
|
||||
<span class="text-ink-faint">
|
||||
({@reputation.verification} verified · {@reputation.votes} votes<span :if={
|
||||
@reputation.at_risk > 0
|
||||
}> · {@reputation.at_risk} at stake in {@stake_summary.at_risk} {if(
|
||||
@stake_summary.at_risk == 1,
|
||||
do: "report",
|
||||
else: "reports"
|
||||
)}</span><span
|
||||
:if={@reputation.slashed > 0}
|
||||
class="text-signal"
|
||||
> · {@reputation.slashed} slashed across {@stake_summary.slashed} {if(
|
||||
@stake_summary.slashed == 1,
|
||||
do: "report",
|
||||
else: "reports"
|
||||
)}</span>)
|
||||
</span>
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<.icon name="hero-clock" class="size-3.5" /> joined {joined_on(@account.inserted_at)}
|
||||
</span>
|
||||
<a
|
||||
:for={identity <- @account.identities}
|
||||
:if={identity.profile_url}
|
||||
id={"profile-identity-#{identity.provider}"}
|
||||
href={identity.profile_url}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
class="inline-flex items-center gap-1.5 transition hover:text-signal"
|
||||
>
|
||||
<.icon name="hero-arrow-up-right" class="size-3.5" />
|
||||
{provider_label(identity.provider)}<span :if={identity.provider_login}>/{identity.provider_login}</span>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section
|
||||
id="profile-stats"
|
||||
class="mt-8 grid grid-cols-2 border-2 border-strong lg:grid-cols-5"
|
||||
>
|
||||
<div class="border-b border-r border-rule px-6 py-7 lg:border-b-0">
|
||||
<p class="text-xs font-medium text-ink-faint">Reports</p>
|
||||
<p
|
||||
id="profile-review-count"
|
||||
class="mt-3 font-display text-4xl font-medium tabular-nums text-ink"
|
||||
>
|
||||
{@stats.reviews}
|
||||
</p>
|
||||
</div>
|
||||
<div class="border-b border-rule px-6 py-7 lg:border-b-0 lg:border-r">
|
||||
<p class="text-xs font-medium text-ink-faint">Findings</p>
|
||||
<p
|
||||
id="profile-finding-count"
|
||||
class="mt-3 font-display text-4xl font-medium tabular-nums text-signal"
|
||||
>
|
||||
{@stats.findings}
|
||||
</p>
|
||||
</div>
|
||||
<div class="border-b border-r border-rule px-6 py-7 lg:border-b-0">
|
||||
<p class="text-xs font-medium text-ink-faint">Checks</p>
|
||||
<p
|
||||
id="profile-verdict-count"
|
||||
class="mt-3 font-display text-4xl font-medium tabular-nums text-ink"
|
||||
>
|
||||
{@stats.verdicts}
|
||||
</p>
|
||||
</div>
|
||||
<div class="border-b border-rule px-6 py-7 lg:border-b-0 lg:border-r">
|
||||
<p class="text-xs font-medium text-ink-faint">
|
||||
Repositories
|
||||
</p>
|
||||
<p
|
||||
id="profile-repo-count"
|
||||
class="mt-3 font-display text-4xl font-medium tabular-nums text-ink"
|
||||
>
|
||||
{@stats.repositories}
|
||||
</p>
|
||||
</div>
|
||||
<div class="px-6 py-7">
|
||||
<p class="text-xs font-medium text-ink-faint">Credits</p>
|
||||
<p
|
||||
id="profile-credit-balance"
|
||||
class="mt-3 font-display text-4xl font-medium tabular-nums text-ink"
|
||||
>
|
||||
{@credit_balance}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="mt-10 grid gap-10 lg:grid-cols-2">
|
||||
<section id="profile-repositories">
|
||||
<h2 class="font-display text-lg uppercase tracking-[0.02em] text-ink">
|
||||
Repositories added
|
||||
</h2>
|
||||
<p class="mt-1 font-mono text-[11px] text-ink-faint">
|
||||
Publicly listed repositories this contributor registered.
|
||||
</p>
|
||||
|
||||
<p
|
||||
:if={@repositories == []}
|
||||
id="profile-no-repositories"
|
||||
class="mt-4 font-mono text-xs text-ink-faint"
|
||||
>
|
||||
No listed repositories yet.
|
||||
</p>
|
||||
|
||||
<ul
|
||||
:if={@repositories != []}
|
||||
class="mt-4 divide-y divide-rule border-t border-rule"
|
||||
>
|
||||
<li :for={repository <- @repositories} id={"profile-repo-#{repository.id}"}>
|
||||
<.link
|
||||
navigate={TarakanWeb.RepositoryPaths.repository_path(repository)}
|
||||
class="group flex items-baseline gap-x-3 px-3 py-3 font-mono text-xs transition-colors hover:bg-panel"
|
||||
>
|
||||
<span class="shrink-0 tabular-nums text-ink-faint">
|
||||
{activity_time(repository.inserted_at)}
|
||||
</span>
|
||||
<span class="min-w-0 truncate text-ink-muted transition group-hover:text-ink">
|
||||
<span class="text-ink-faint">{repository.host}/</span>{repository.owner}/{repository.name}
|
||||
</span>
|
||||
<span class="ml-auto shrink-0 text-[10px] text-ink-faint">
|
||||
<span :if={repository.primary_language}>{repository.primary_language}</span>
|
||||
</span>
|
||||
</.link>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="profile-reviews">
|
||||
<h2 class="font-display text-lg uppercase tracking-[0.02em] text-ink">
|
||||
Reports submitted
|
||||
</h2>
|
||||
<p class="mt-1 font-mono text-[11px] text-ink-faint">
|
||||
Security reviews pinned to exact commits.
|
||||
</p>
|
||||
|
||||
<p
|
||||
:if={@reviews == []}
|
||||
id="profile-no-reviews"
|
||||
class="mt-4 font-mono text-xs text-ink-faint"
|
||||
>
|
||||
No public reports yet.
|
||||
</p>
|
||||
|
||||
<ul :if={@reviews != []} class="mt-4 divide-y divide-rule border-t border-rule">
|
||||
<li :for={entry <- @reviews} id={"profile-review-#{entry.id}"}>
|
||||
<.link
|
||||
navigate={TarakanWeb.RepositoryPaths.repository_security_path(entry.repository)}
|
||||
class="group flex items-baseline gap-x-3 px-3 py-3 font-mono text-xs transition-colors hover:bg-panel"
|
||||
>
|
||||
<span class="shrink-0 tabular-nums text-ink-faint">{activity_time(entry.at)}</span>
|
||||
<span class="min-w-0 truncate text-ink-muted transition group-hover:text-ink">
|
||||
<span class="text-ink-faint">reviewed</span>
|
||||
{entry.repository.owner}/{entry.repository.name}
|
||||
<span :if={entry.commit_sha} class="text-ink-faint">
|
||||
· {short_sha(entry.commit_sha)}
|
||||
</span>
|
||||
</span>
|
||||
<span class="ml-auto shrink-0 text-[10px] text-ink-faint">
|
||||
<span :if={entry.verified} class="text-ink-muted">verified · </span>
|
||||
<span :if={entry.findings_count > 0} class="text-signal">
|
||||
{entry.findings_count} findings
|
||||
</span>
|
||||
<span :if={entry.findings_count == 0}>no findings</span>
|
||||
</span>
|
||||
</.link>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="profile-findings">
|
||||
<h2 class="font-display text-lg uppercase tracking-[0.02em] text-ink">
|
||||
Findings filed
|
||||
</h2>
|
||||
<p class="mt-1 font-mono text-[11px] text-ink-faint">
|
||||
Individual findings from public reports.
|
||||
</p>
|
||||
|
||||
<p
|
||||
:if={@findings == []}
|
||||
id="profile-no-findings"
|
||||
class="mt-4 font-mono text-xs text-ink-faint"
|
||||
>
|
||||
No public findings yet.
|
||||
</p>
|
||||
|
||||
<ul :if={@findings != []} class="mt-4 divide-y divide-rule border-t border-rule">
|
||||
<li :for={entry <- @findings} id={"profile-finding-#{entry.public_id}"}>
|
||||
<.link
|
||||
navigate={~p"/findings/#{entry.public_id}"}
|
||||
class="group flex items-baseline gap-x-3 px-3 py-3 font-mono text-xs transition-colors hover:bg-panel"
|
||||
>
|
||||
<span class="shrink-0 tabular-nums text-ink-faint">{activity_time(entry.at)}</span>
|
||||
<span class="min-w-0 truncate text-ink-muted transition group-hover:text-ink">
|
||||
<span class={[
|
||||
"mr-1.5 inline-block border px-1 text-[10px] uppercase tracking-[0.12em]",
|
||||
entry.severity in ~w(critical high) && "border-signal text-signal",
|
||||
entry.severity not in ~w(critical high) && "border-rule text-ink-faint"
|
||||
]}>
|
||||
{entry.severity}
|
||||
</span>
|
||||
{entry.title}
|
||||
</span>
|
||||
<span class="ml-auto shrink-0 text-[10px] text-ink-faint">
|
||||
{entry.repository.owner}/{entry.repository.name}
|
||||
</span>
|
||||
</.link>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="profile-checks">
|
||||
<h2 class="font-display text-lg uppercase tracking-[0.02em] text-ink">
|
||||
Checks cast
|
||||
</h2>
|
||||
<p class="mt-1 font-mono text-[11px] text-ink-faint">
|
||||
Independent confirmations, disputes, and fix notes.
|
||||
</p>
|
||||
|
||||
<p
|
||||
:if={@checks == []}
|
||||
id="profile-no-checks"
|
||||
class="mt-4 font-mono text-xs text-ink-faint"
|
||||
>
|
||||
No public checks yet.
|
||||
</p>
|
||||
|
||||
<ul :if={@checks != []} class="mt-4 divide-y divide-rule border-t border-rule">
|
||||
<li :for={entry <- @checks} id={"profile-check-#{entry.id}"}>
|
||||
<.link
|
||||
:if={entry.public_id || entry.repository}
|
||||
navigate={check_path(entry)}
|
||||
class="group flex items-baseline gap-x-3 px-3 py-3 font-mono text-xs transition-colors hover:bg-panel"
|
||||
>
|
||||
<span class="shrink-0 tabular-nums text-ink-faint">{activity_time(entry.at)}</span>
|
||||
<span class="min-w-0 truncate text-ink-muted transition group-hover:text-ink">
|
||||
<span class={[
|
||||
entry.verdict == "confirmed" && "text-quote",
|
||||
entry.verdict == "disputed" && "text-signal",
|
||||
entry.verdict == "fixed" && "text-ink"
|
||||
]}>
|
||||
{entry.verdict}
|
||||
</span>
|
||||
<span :if={entry.title} class="text-ink-faint"> · </span>
|
||||
<span :if={entry.title}>{entry.title}</span>
|
||||
<span
|
||||
:if={is_nil(entry.title) and entry.repository}
|
||||
class="text-ink-faint"
|
||||
>
|
||||
on {entry.repository.owner}/{entry.repository.name}
|
||||
</span>
|
||||
</span>
|
||||
<span class="ml-auto shrink-0 text-[10px] text-ink-faint">
|
||||
{provenance_label(entry.provenance)}
|
||||
</span>
|
||||
</.link>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section id="profile-credits" class="mt-10">
|
||||
<h2 class="font-display text-lg uppercase tracking-[0.02em] text-ink">
|
||||
Credits
|
||||
</h2>
|
||||
<p class="mt-1 font-mono text-[11px] text-ink-faint">
|
||||
Earned on verification events only - never purchasable, never transferable.
|
||||
</p>
|
||||
|
||||
<p
|
||||
:if={@credit_entries == []}
|
||||
id="profile-no-credits"
|
||||
class="mt-4 font-mono text-xs text-ink-faint"
|
||||
>
|
||||
No credit activity yet.
|
||||
</p>
|
||||
|
||||
<ul :if={@credit_entries != []} class="mt-4 divide-y divide-rule border-t border-rule">
|
||||
<li
|
||||
:for={entry <- @credit_entries}
|
||||
id={"profile-credit-#{entry.id}"}
|
||||
class="flex items-baseline gap-x-3 px-3 py-3 font-mono text-xs"
|
||||
>
|
||||
<span class="shrink-0 tabular-nums text-ink-faint">
|
||||
{activity_time(entry.inserted_at)}
|
||||
</span>
|
||||
<span class="min-w-0 truncate text-ink-muted">
|
||||
{credit_kind_label(entry.kind)}
|
||||
</span>
|
||||
<span class={[
|
||||
"ml-auto shrink-0 tabular-nums",
|
||||
entry.amount > 0 && "text-quote",
|
||||
entry.amount < 0 && "text-signal"
|
||||
]}>
|
||||
{credit_amount(entry.amount)}
|
||||
</span>
|
||||
<span class="shrink-0 tabular-nums text-ink-faint">
|
||||
balance {entry.balance_after}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</Layouts.page>
|
||||
</Layouts.app>
|
||||
165
lib/tarakan_web/live/account_live/registration.ex
Normal file
165
lib/tarakan_web/live/account_live/registration.ex
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
defmodule TarakanWeb.AccountLive.Registration do
|
||||
use TarakanWeb, :live_view
|
||||
|
||||
alias Tarakan.Accounts
|
||||
alias Tarakan.Accounts.Account
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
|
||||
<Layouts.page width={:form}>
|
||||
<div class="text-center">
|
||||
<.header>
|
||||
Join Tarakan
|
||||
<:subtitle>
|
||||
Fastest path: GitHub. Already here?
|
||||
<.link navigate={~p"/accounts/log-in"} class="font-semibold text-signal hover:underline">
|
||||
Log in
|
||||
</.link>
|
||||
</:subtitle>
|
||||
</.header>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 space-y-3">
|
||||
<.link
|
||||
id="github-register-button"
|
||||
href={~p"/auth/github?return_to=/"}
|
||||
class="clip-notch flex h-12 w-full items-center justify-center bg-btn px-4 font-display text-sm uppercase tracking-[0.12em] text-btn-fg transition hover:opacity-90"
|
||||
>
|
||||
Continue with GitHub
|
||||
</.link>
|
||||
<.link
|
||||
id="gitlab-register-button"
|
||||
href={~p"/auth/gitlab?return_to=/"}
|
||||
class="flex h-12 w-full items-center justify-center border border-strong px-4 font-display text-sm uppercase tracking-[0.12em] text-ink transition hover:bg-panel"
|
||||
>
|
||||
Continue with GitLab
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
<details class="mt-6 group border-2 border-rule">
|
||||
<summary class="cursor-pointer px-4 py-3 font-mono text-[11px] uppercase tracking-[0.12em] text-ink-faint transition hover:text-ink">
|
||||
Prefer email instead
|
||||
</summary>
|
||||
<.form
|
||||
for={@form}
|
||||
id="registration_form"
|
||||
phx-submit="save"
|
||||
phx-change="validate"
|
||||
class="space-y-4 border-t-2 border-rule bg-panel p-6"
|
||||
>
|
||||
<.input
|
||||
field={@form[:handle]}
|
||||
type="text"
|
||||
label="Handle"
|
||||
autocomplete="username"
|
||||
spellcheck="false"
|
||||
placeholder="signalghost"
|
||||
required
|
||||
/>
|
||||
<.input
|
||||
field={@form[:email]}
|
||||
type="email"
|
||||
label="Email"
|
||||
autocomplete="email"
|
||||
spellcheck="false"
|
||||
required
|
||||
/>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
phx-disable-with="Creating account..."
|
||||
class="clip-notch inline-flex h-11 w-full items-center justify-center bg-btn px-4 font-display text-sm uppercase tracking-[0.12em] text-btn-fg transition hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-phosphor"
|
||||
>
|
||||
Email me a link
|
||||
</button>
|
||||
</.form>
|
||||
</details>
|
||||
|
||||
<.form
|
||||
for={@login_form}
|
||||
id="registration_login_form"
|
||||
action={~p"/accounts/log-in"}
|
||||
phx-trigger-action={@trigger_login}
|
||||
class={["hidden"]}
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name={@login_form[:session_token].name}
|
||||
value={@login_form[:session_token].value}
|
||||
/>
|
||||
<input type="hidden" name={@login_form[:remember_me].name} value="true" />
|
||||
</.form>
|
||||
</Layouts.page>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, %{assigns: %{current_scope: %{account: account}}} = socket)
|
||||
when not is_nil(account) do
|
||||
{:ok, redirect(socket, to: TarakanWeb.AccountAuth.signed_in_path(socket))}
|
||||
end
|
||||
|
||||
def mount(_params, _session, socket) do
|
||||
changeset = Accounts.change_account_registration(%Account{}, %{}, validate_unique: false)
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign_form(changeset)
|
||||
|> assign(:login_form, to_form(%{"session_token" => ""}, as: "account"))
|
||||
|> assign(:trigger_login, false), temporary_assigns: [form: nil]}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("save", %{"account" => account_params}, socket) do
|
||||
if TarakanWeb.BrowserRateLimit.allowed?(:registration_ip, socket.assigns.client_ip) do
|
||||
register_account(socket, account_params)
|
||||
else
|
||||
registration_limited(socket)
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("validate", %{"account" => account_params}, socket) do
|
||||
changeset =
|
||||
Accounts.change_account_registration(%Account{}, account_params, validate_unique: false)
|
||||
|
||||
{:noreply, assign_form(socket, Map.put(changeset, :action, :validate))}
|
||||
end
|
||||
|
||||
# New accounts establish their first session immediately (via a one-time
|
||||
# session token that does not confirm the email). Existing credentials keep
|
||||
# the generic email outcome so the form does not disclose account details.
|
||||
defp register_account(socket, account_params) do
|
||||
case Accounts.request_registration(account_params, &url(~p"/accounts/log-in/#{&1}")) do
|
||||
{:ok, {:created, token}} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:login_form, to_form(%{"session_token" => token}, as: "account"))
|
||||
|> assign(:trigger_login, true)}
|
||||
|
||||
{:ok, :accepted} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(
|
||||
:info,
|
||||
"If an account matches those details, a sign-in link will arrive shortly."
|
||||
)
|
||||
|> push_navigate(to: ~p"/accounts/log-in")}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign_form(socket, Map.put(changeset, :action, :insert))}
|
||||
end
|
||||
end
|
||||
|
||||
defp registration_limited(socket) do
|
||||
{:noreply,
|
||||
put_flash(socket, :error, "Too many account attempts from this network. Try again later.")}
|
||||
end
|
||||
|
||||
defp assign_form(socket, %Ecto.Changeset{} = changeset) do
|
||||
form = to_form(changeset, as: "account")
|
||||
assign(socket, form: form)
|
||||
end
|
||||
end
|
||||
918
lib/tarakan_web/live/account_live/settings.ex
Normal file
918
lib/tarakan_web/live/account_live/settings.ex
Normal file
|
|
@ -0,0 +1,918 @@
|
|||
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"""
|
||||
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
|
||||
<Layouts.page width={:wide}>
|
||||
<div class="mx-auto w-full max-w-5xl">
|
||||
<header class="border-b-2 border-strong pb-5">
|
||||
<h1 class="font-display text-2xl font-medium uppercase leading-9 tracking-[0.02em] text-ink">
|
||||
Account settings
|
||||
</h1>
|
||||
<p class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 font-mono text-xs">
|
||||
<span class="text-ink-muted">@{@current_scope.account.handle}</span>
|
||||
<span class="text-ink-faint">{@current_scope.account.email}</span>
|
||||
<span class={if @email_confirmed?, do: "text-phosphor", else: "text-signal"}>
|
||||
{if @email_confirmed?, do: "confirmed", else: "unconfirmed"}
|
||||
</span>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="mt-6 grid gap-6 lg:grid-cols-[13rem_minmax(0,1fr)] lg:gap-8">
|
||||
<nav
|
||||
id="settings-navigation"
|
||||
aria-label="Account settings"
|
||||
class="flex overflow-x-auto overscroll-x-contain border-b-2 border-rule text-sm [-ms-overflow-style:none] [scrollbar-width:none] lg:sticky lg:top-20 lg:block lg:self-start lg:overflow-visible lg:border-b-0 lg:border-l-2 [&::-webkit-scrollbar]:hidden"
|
||||
>
|
||||
<.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)}
|
||||
/>
|
||||
</nav>
|
||||
|
||||
<div class="min-w-0">
|
||||
<.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} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layouts.page>
|
||||
</Layouts.app>
|
||||
"""
|
||||
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" />
|
||||
<span>{@label}</span>
|
||||
<span :if={@badge} class={["font-mono text-[11px] tabular-nums lg:ml-auto", @badge_class]}>
|
||||
{@badge}
|
||||
</span>
|
||||
</.link>
|
||||
"""
|
||||
end
|
||||
|
||||
defp account_section(assigns) do
|
||||
~H"""
|
||||
<div class="space-y-5">
|
||||
<.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"
|
||||
>
|
||||
<h2 class="text-sm font-semibold text-ink">Email address</h2>
|
||||
<.input
|
||||
field={@email_form[:email]}
|
||||
type="email"
|
||||
label="Email"
|
||||
autocomplete="username"
|
||||
spellcheck="false"
|
||||
required
|
||||
/>
|
||||
<.button variant="primary" phx-disable-with="Changing...">Save email</.button>
|
||||
</.form>
|
||||
|
||||
<section
|
||||
:if={not @email_confirmed?}
|
||||
id="email-confirmation-required"
|
||||
class="border-2 border-strong bg-panel p-6"
|
||||
>
|
||||
<h2 class="text-sm font-semibold text-ink">Confirm your email to unlock credentials</h2>
|
||||
<p class="mt-1 text-xs leading-5 text-ink-faint">
|
||||
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</.link>
|
||||
- to confirm it.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<.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"
|
||||
>
|
||||
<h2 class="text-sm font-semibold text-ink">Password</h2>
|
||||
<p class="text-xs leading-5 text-ink-faint">
|
||||
At least 15 characters.
|
||||
</p>
|
||||
<.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
|
||||
</.button>
|
||||
</.form>
|
||||
|
||||
<section class="border-2 border-strong bg-panel p-6">
|
||||
<h2 class="text-sm font-semibold text-ink">Connected code hosts</h2>
|
||||
<div class="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
<.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"
|
||||
>
|
||||
<span>GitHub</span>
|
||||
<span class={
|
||||
if MapSet.member?(@identity_providers, "github"),
|
||||
do: "text-signal",
|
||||
else: "text-ink-faint"
|
||||
}>
|
||||
{if MapSet.member?(@identity_providers, "github"), do: "Connected", else: "Connect"}
|
||||
</span>
|
||||
</.link>
|
||||
<.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"
|
||||
>
|
||||
<span>GitLab</span>
|
||||
<span class={
|
||||
if MapSet.member?(@identity_providers, "gitlab"),
|
||||
do: "text-signal",
|
||||
else: "text-ink-faint"
|
||||
}>
|
||||
{if MapSet.member?(@identity_providers, "gitlab"), do: "Connected", else: "Connect"}
|
||||
</span>
|
||||
</.link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp credentials_section(assigns) do
|
||||
~H"""
|
||||
<section id="api-token" class="border-2 border-strong bg-panel p-6">
|
||||
<h2 class="text-sm font-semibold text-ink">Client credentials</h2>
|
||||
<p class="mt-1 text-xs leading-5 text-ink-faint">
|
||||
<span class="font-mono">TARAKAN_API_TOKEN</span>, sent as <span class="font-mono">Authorization: Bearer <token></span>. Defaults expire after {ApiCredentials.default_validity_days()} days (max {ApiCredentials.maximum_validity_days()}).
|
||||
</p>
|
||||
<div :if={@api_token} class="mt-4">
|
||||
<p class="font-mono text-[11px] text-ink-faint">
|
||||
Shown only once.
|
||||
</p>
|
||||
<p
|
||||
id="api-token-value"
|
||||
class="mt-1 break-all border-2 border-rule px-3 py-2 font-mono text-xs text-ink"
|
||||
>
|
||||
{@api_token}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
:if={@api_credentials != []}
|
||||
id="api-credentials"
|
||||
class="mt-4 divide-y-2 divide-rule border-y-2 border-rule"
|
||||
>
|
||||
<div
|
||||
:for={credential <- @api_credentials}
|
||||
id={"api-credential-#{credential.id}"}
|
||||
class="flex items-center justify-between gap-4 py-3"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-xs font-semibold text-ink">{credential.name}</p>
|
||||
<p class="mt-1 font-mono text-[11px] text-ink-faint">
|
||||
{credential.token_prefix}… · {credential_status(credential)}
|
||||
</p>
|
||||
<p class="mt-1 font-mono text-[11px] text-ink-faint">
|
||||
{Enum.join(credential.scopes, " · ")}
|
||||
<span :if={credential.repository}>
|
||||
· {credential.repository.owner}/{credential.repository.name}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<.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
|
||||
</.button>
|
||||
</div>
|
||||
</div>
|
||||
<.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
|
||||
/>
|
||||
<fieldset>
|
||||
<legend class="text-xs font-semibold text-ink">Permissions</legend>
|
||||
<div class="mt-2 grid gap-2 sm:grid-cols-2">
|
||||
<label
|
||||
:for={scope <- ApiCredential.scopes()}
|
||||
class="flex items-start gap-2 border-2 border-rule px-3 py-2 text-xs text-ink-muted"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="credential[scopes][]"
|
||||
value={scope}
|
||||
checked={scope in @credential_form.params["scopes"]}
|
||||
class="mt-0.5 border-rule text-signal focus:ring-phosphor"
|
||||
/>
|
||||
<span>
|
||||
<span class="block font-mono text-ink">{scope}</span>
|
||||
<span class="mt-0.5 block text-ink-faint">{credential_scope_label(scope)}</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<.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
|
||||
</.button>
|
||||
</.form>
|
||||
|
||||
<details id="api-reference" class="group mt-5 border-t-2 border-rule pt-5">
|
||||
<summary class="flex cursor-pointer list-none items-center justify-between gap-4 text-sm font-semibold text-ink marker:hidden">
|
||||
<span>API reference</span>
|
||||
<span class="font-mono text-[11px] font-normal uppercase tracking-[0.12em] text-ink-faint group-open:hidden">
|
||||
Open
|
||||
</span>
|
||||
<span class="hidden font-mono text-[11px] font-normal uppercase tracking-[0.12em] text-ink-faint group-open:inline">
|
||||
Close
|
||||
</span>
|
||||
</summary>
|
||||
|
||||
<div class="mt-4 space-y-5 text-xs leading-5 text-ink-muted">
|
||||
<div>
|
||||
<p class="font-semibold text-ink">Authentication</p>
|
||||
<p class="mt-1">
|
||||
Base URL:
|
||||
<code id="api-reference-base-url" class="font-mono text-ink">{@api_base_url}</code>
|
||||
</p>
|
||||
<p>
|
||||
Send <code class="font-mono text-ink">Authorization: Bearer <token></code>
|
||||
and <code class="font-mono text-ink">Accept: application/json</code>.
|
||||
</p>
|
||||
<pre class="mt-2 overflow-x-auto border-2 border-rule bg-ground p-3 font-mono text-[11px] leading-5 text-ink"><code>curl "{@api_base_url}/jobs" \
|
||||
-H "Authorization: Bearer $TARAKAN_API_TOKEN" \
|
||||
-H "Accept: application/json"</code></pre>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="font-semibold text-ink">Client authorization</p>
|
||||
<dl class="mt-2 grid gap-x-3 gap-y-1 font-mono text-[11px] sm:grid-cols-[4rem_1fr]">
|
||||
<dt class="text-ink">POST</dt><dd>/client-auth/start</dd>
|
||||
<dt class="text-ink">POST</dt><dd>/client-auth/exchange</dd>
|
||||
<dt class="text-ink">DELETE</dt><dd>/client-auth/session</dd>
|
||||
</dl>
|
||||
<p class="mt-2">
|
||||
Start with <code class="font-mono text-ink">{"client_name":"Tarakan Client"}</code>,
|
||||
open the returned verification URL, then poll exchange with
|
||||
<code class="font-mono text-ink">{"device_code":"..."}</code>
|
||||
at the returned interval. Session revocation requires the issued bearer token.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="font-semibold text-ink">Repository discovery</p>
|
||||
<dl class="mt-2 grid gap-x-3 gap-y-1 font-mono text-[11px] sm:grid-cols-[4rem_1fr]">
|
||||
<dt class="text-ink">GET</dt><dd>/repositories</dd>
|
||||
<dt class="text-ink">POST</dt><dd>/repositories</dd>
|
||||
</dl>
|
||||
<p class="mt-2">
|
||||
List with <code class="font-mono text-ink">status=unscanned</code>
|
||||
and <code class="font-mono text-ink">limit=100</code>.
|
||||
Register with
|
||||
<code class="font-mono text-ink">{"url":"owner/name"}</code>
|
||||
(scopes: findings:submit or reviews:submit).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="font-semibold text-ink">Jobs</p>
|
||||
<dl class="mt-2 grid gap-x-3 gap-y-1 font-mono text-[11px] sm:grid-cols-[4rem_1fr]">
|
||||
<dt class="text-ink">GET</dt><dd>/jobs</dd>
|
||||
<dt class="text-ink">GET</dt><dd>/:host/:owner/:name/jobs</dd>
|
||||
<dt class="text-ink">GET</dt><dd>/jobs/:id</dd>
|
||||
<dt class="text-ink">POST</dt><dd>/jobs/:id/claim</dd>
|
||||
<dt class="text-ink">POST</dt><dd>/jobs/:id/claim/renew</dd>
|
||||
<dt class="text-ink">DELETE</dt><dd>/jobs/:id/claim</dd>
|
||||
<dt class="text-ink">POST</dt><dd>/jobs/:id/complete</dd>
|
||||
</dl>
|
||||
<p class="mt-2">
|
||||
Scopes: <code class="font-mono text-ink">tasks:read</code>, <code class="font-mono text-ink">tasks:claim</code>, and <code class="font-mono text-ink">contributions:write</code>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="font-semibold text-ink">Reports and Checks</p>
|
||||
<dl class="mt-2 grid gap-x-3 gap-y-1 font-mono text-[11px] sm:grid-cols-[4rem_1fr]">
|
||||
<dt class="text-ink">GET</dt><dd>/:host/:owner/:name/reports</dd>
|
||||
<dt class="text-ink">POST</dt><dd>/:host/:owner/:name/reports</dd>
|
||||
<dt class="text-ink">GET</dt><dd>/:host/:owner/:name/memory</dd>
|
||||
<dt class="text-ink">POST</dt><dd>/:host/:owner/:name/reports/:id/check</dd>
|
||||
<dt class="text-ink">POST</dt><dd>
|
||||
/:host/:owner/:name/findings/:public_id/check
|
||||
</dd>
|
||||
</dl>
|
||||
<p class="mt-2">
|
||||
Use <code class="font-mono text-ink">reviews:submit</code>
|
||||
to publish, <code class="font-mono text-ink">reviews:read</code>
|
||||
for restricted evidence,
|
||||
and <code class="font-mono text-ink">reviews:verify</code>
|
||||
to check findings.
|
||||
Checks also require qualified reviewer standing.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="border-l-2 border-rule pl-3">
|
||||
<p class="font-semibold text-ink">Repository host</p>
|
||||
<p class="mt-1">
|
||||
Use <code class="font-mono text-ink">github.com</code>
|
||||
or <code class="font-mono text-ink">tarakan.lol</code>
|
||||
for <code class="font-mono text-ink">:host</code>. JSON bodies only;
|
||||
validation failures return field errors with a non-2xx status.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
"""
|
||||
end
|
||||
|
||||
defp ssh_section(assigns) do
|
||||
~H"""
|
||||
<section id="ssh-keys" class="border-2 border-strong bg-panel p-6">
|
||||
<h2 class="text-sm font-semibold text-ink">SSH keys</h2>
|
||||
<p class="mt-1 font-mono text-xs leading-5 text-ink-faint">
|
||||
git clone ssh://git@<host>/{@handle}/<name>.git
|
||||
</p>
|
||||
<div
|
||||
:if={@ssh_keys != []}
|
||||
id="ssh-key-list"
|
||||
class="mt-4 divide-y-2 divide-rule border-y-2 border-rule"
|
||||
>
|
||||
<div
|
||||
:for={key <- @ssh_keys}
|
||||
id={"ssh-key-#{key.id}"}
|
||||
class="flex items-center justify-between gap-4 py-3"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-xs font-semibold text-ink">{key.name}</p>
|
||||
<p class="mt-1 break-all font-mono text-[11px] text-ink-faint">
|
||||
{key.fingerprint_sha256}
|
||||
</p>
|
||||
<p class="mt-1 font-mono text-[11px] text-ink-faint">
|
||||
{key.key_type} · {ssh_key_last_used(key)}
|
||||
</p>
|
||||
</div>
|
||||
<.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
|
||||
</.button>
|
||||
</div>
|
||||
</div>
|
||||
<.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
|
||||
/>
|
||||
<p class="text-xs leading-5 text-ink-faint">
|
||||
ed25519, ECDSA, and RSA (3072-bit or larger) keys are accepted.
|
||||
</p>
|
||||
<.button id="add-ssh-key-button" variant="primary">Add SSH key</.button>
|
||||
</.form>
|
||||
</section>
|
||||
"""
|
||||
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"""
|
||||
<section id="settings-model-scoreboard" class="border-2 border-strong bg-panel p-6">
|
||||
<div class="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
|
||||
<h2 class="text-sm font-semibold text-ink">Your agents</h2>
|
||||
<.link navigate={~p"/models"} class="font-mono text-[11px] text-signal hover:underline">
|
||||
All models →
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
<p :if={@model_scoreboard == []} class="mt-4 font-mono text-xs text-ink-faint">
|
||||
None yet.
|
||||
</p>
|
||||
|
||||
<div
|
||||
:if={@model_scoreboard != []}
|
||||
class="mt-4 overflow-x-auto border-2 border-rule bg-ground"
|
||||
>
|
||||
<table class="w-full min-w-[28rem] border-collapse text-left">
|
||||
<thead>
|
||||
<tr class="border-b border-rule font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint">
|
||||
<th class="px-3 py-2 font-normal">Model</th>
|
||||
<th class="w-20 px-2 py-2 text-right font-normal">Findings</th>
|
||||
<th class="w-24 px-2 py-2 text-right font-normal">Confirmed</th>
|
||||
<th class="w-24 px-2 py-2 text-right font-normal">Disputed</th>
|
||||
<th class="w-24 px-3 py-2 text-right font-normal">Precision</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-rule">
|
||||
<tr :for={row <- @model_scoreboard} id={"settings-model-#{row.model}"}>
|
||||
<td class="min-w-0 px-3 py-2.5">
|
||||
<span class="block truncate font-mono text-xs font-semibold text-ink">
|
||||
{row.model}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-2 py-2.5 text-right font-mono text-xs tabular-nums text-ink">
|
||||
{row.findings}
|
||||
</td>
|
||||
<td class="px-2 py-2.5 text-right font-mono text-xs tabular-nums text-phosphor">
|
||||
{row.confirmed}
|
||||
</td>
|
||||
<td class="px-2 py-2.5 text-right font-mono text-xs tabular-nums text-ink-muted">
|
||||
{row.disputed}
|
||||
</td>
|
||||
<td class="px-3 py-2.5 text-right font-mono text-xs tabular-nums text-ink">
|
||||
{if row.precision, do: "#{row.precision}%", else: "-"}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
"""
|
||||
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
|
||||
75
lib/tarakan_web/live/admin_live/index.ex
Normal file
75
lib/tarakan_web/live/admin_live/index.ex
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
defmodule TarakanWeb.AdminLive.Index do
|
||||
use TarakanWeb, :live_view
|
||||
|
||||
alias Tarakan.Accounts
|
||||
alias Tarakan.Policy
|
||||
alias TarakanWeb.AccountAuth
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
scope = socket.assigns.current_scope
|
||||
|
||||
cond do
|
||||
not Policy.allowed?(scope, :administer) ->
|
||||
{:ok, unauthorized(socket)}
|
||||
|
||||
not Accounts.sudo_mode?(scope.account) ->
|
||||
{:ok,
|
||||
socket
|
||||
|> put_flash(:error, "Confirm it's you before opening platform administration.")
|
||||
|> redirect(to: AccountAuth.reauth_path(~p"/admin"))}
|
||||
|
||||
true ->
|
||||
with {:ok, accounts} <- Accounts.list_accounts_for_admin(scope),
|
||||
{:ok, summary} <- Accounts.account_admin_summary(scope) do
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Platform administration")
|
||||
|> assign(:summary, summary)
|
||||
|> assign(:account_count, length(accounts))
|
||||
|> assign(:filter_form, to_form(%{"query" => ""}, as: :filters))
|
||||
|> stream(:accounts, accounts)}
|
||||
else
|
||||
{:error, _reason} -> {:ok, unauthorized(socket)}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("filter", %{"filters" => %{"query" => query}}, socket) do
|
||||
with_recent_auth(socket, fn ->
|
||||
case Accounts.list_accounts_for_admin(socket.assigns.current_scope, query) do
|
||||
{:ok, accounts} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:account_count, length(accounts))
|
||||
|> assign(:filter_form, to_form(%{"query" => query}, as: :filters))
|
||||
|> stream(:accounts, accounts, reset: true)}
|
||||
|
||||
{:error, _reason} ->
|
||||
{:noreply, unauthorized(socket)}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
def handle_event("filter", _params, socket), do: {:noreply, socket}
|
||||
|
||||
# The mount-time sudo check only covers the initial render; a long-lived
|
||||
# LiveView can outlast the two-hour window, so admin events re-check.
|
||||
defp with_recent_auth(socket, fun) do
|
||||
if Accounts.sudo_mode?(socket.assigns.current_scope.account) do
|
||||
fun.()
|
||||
else
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, "Confirm it's you before opening platform administration.")
|
||||
|> push_navigate(to: AccountAuth.reauth_path(~p"/admin"))}
|
||||
end
|
||||
end
|
||||
|
||||
defp unauthorized(socket) do
|
||||
socket
|
||||
|> put_flash(:error, "Administrator access is required.")
|
||||
|> redirect(to: ~p"/")
|
||||
end
|
||||
end
|
||||
121
lib/tarakan_web/live/admin_live/index.html.heex
Normal file
121
lib/tarakan_web/live/admin_live/index.html.heex
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
|
||||
<Layouts.page id="admin-dashboard">
|
||||
<.breadcrumbs>
|
||||
<:crumb navigate={~p"/"}>registry</:crumb>
|
||||
<:crumb>admin</:crumb>
|
||||
</.breadcrumbs>
|
||||
|
||||
<header class={[
|
||||
"mt-4 flex flex-col gap-5 border-b-2 border-strong pb-7 sm:flex-row sm:items-end sm:justify-between"
|
||||
]}>
|
||||
<div>
|
||||
<h1 class={["font-display text-3xl uppercase tracking-[0.02em] text-ink sm:text-5xl"]}>
|
||||
Administration
|
||||
</h1>
|
||||
<p class={["mt-3 max-w-2xl text-sm leading-6 text-ink-muted"]}>
|
||||
Manage account standing and platform authority. Every change is re-authorized against locked database state and written to the audit record.
|
||||
</p>
|
||||
</div>
|
||||
<.link
|
||||
id="admin-moderation-link"
|
||||
navigate={~p"/moderation/queue"}
|
||||
class={[
|
||||
"font-mono text-xs uppercase tracking-[0.12em] text-signal transition hover:underline"
|
||||
]}
|
||||
>
|
||||
Moderation queue →
|
||||
</.link>
|
||||
</header>
|
||||
|
||||
<section id="admin-summary" class={["mt-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-4"]}>
|
||||
<%= for {id, label, value} <- [
|
||||
{"total", "Accounts", @summary.total},
|
||||
{"admins", "Administrators", @summary.admins},
|
||||
{"moderators", "Moderators", @summary.moderators},
|
||||
{"restricted", "Restricted", @summary.restricted}
|
||||
] do %>
|
||||
<article id={"admin-summary-#{id}"} class={["border-2 border-strong bg-panel px-5 py-5"]}>
|
||||
<p class={["text-xs font-medium text-ink-faint"]}>
|
||||
{label}
|
||||
</p>
|
||||
<p class={["mt-2 font-display text-3xl text-ink"]}>{value}</p>
|
||||
</article>
|
||||
<% end %>
|
||||
</section>
|
||||
|
||||
<section class={["mt-8"]} aria-labelledby="admin-accounts-heading">
|
||||
<div class={["flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between"]}>
|
||||
<div>
|
||||
<h2
|
||||
id="admin-accounts-heading"
|
||||
class={["font-display text-xl uppercase tracking-[0.02em] text-ink"]}
|
||||
>
|
||||
Accounts
|
||||
</h2>
|
||||
<p id="admin-account-count" class={["mt-1 font-mono text-[11px] text-ink-faint"]}>
|
||||
{@account_count} shown · maximum 100
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<.form
|
||||
for={@filter_form}
|
||||
id="admin-account-filter"
|
||||
phx-change="filter"
|
||||
class={["w-full sm:max-w-sm"]}
|
||||
>
|
||||
<.input
|
||||
field={@filter_form[:query]}
|
||||
type="search"
|
||||
label="Search accounts"
|
||||
placeholder="handle, email, or name"
|
||||
autocomplete="off"
|
||||
phx-debounce="250"
|
||||
/>
|
||||
</.form>
|
||||
</div>
|
||||
|
||||
<div id="admin-accounts" phx-update="stream" class={["mt-5 grid gap-3"]}>
|
||||
<div
|
||||
id="admin-accounts-empty"
|
||||
class={[
|
||||
"hidden border-2 border-rule px-5 py-10 text-center text-sm text-ink-muted only:block"
|
||||
]}
|
||||
>
|
||||
No accounts match that search.
|
||||
</div>
|
||||
|
||||
<article
|
||||
:for={{dom_id, account} <- @streams.accounts}
|
||||
id={dom_id}
|
||||
class={[
|
||||
"group flex flex-col gap-4 border-2 border-strong bg-panel px-5 py-4 transition hover:-translate-y-0.5 hover:shadow-[4px_4px_0_0_var(--color-rule)] sm:flex-row sm:items-center sm:justify-between"
|
||||
]}
|
||||
>
|
||||
<div class={["min-w-0"]}>
|
||||
<div class={["flex flex-wrap items-center gap-2"]}>
|
||||
<h3 class={["font-display text-lg text-ink"]}>@{account.handle}</h3>
|
||||
<span class={[
|
||||
"border border-rule px-2 py-0.5 font-mono text-[10px] uppercase tracking-[0.12em] text-ink-muted"
|
||||
]}>
|
||||
{account.platform_role}
|
||||
</span>
|
||||
<span class={["font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint"]}>
|
||||
{account.state} · {account.trust_tier}
|
||||
</span>
|
||||
</div>
|
||||
<p class={["mt-1 truncate text-xs text-ink-faint"]}>{account.email}</p>
|
||||
</div>
|
||||
<.link
|
||||
id={"admin-account-#{account.id}-manage"}
|
||||
navigate={~p"/admin/accounts/#{account.id}"}
|
||||
class={[
|
||||
"shrink-0 font-mono text-xs uppercase tracking-[0.12em] text-signal transition group-hover:underline"
|
||||
]}
|
||||
>
|
||||
Manage →
|
||||
</.link>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</Layouts.page>
|
||||
</Layouts.app>
|
||||
112
lib/tarakan_web/live/admin_live/show.ex
Normal file
112
lib/tarakan_web/live/admin_live/show.ex
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
defmodule TarakanWeb.AdminLive.Show do
|
||||
use TarakanWeb, :live_view
|
||||
|
||||
alias Tarakan.Accounts
|
||||
alias Tarakan.Accounts.Account
|
||||
alias Tarakan.Policy
|
||||
alias TarakanWeb.AccountAuth
|
||||
|
||||
@impl true
|
||||
def mount(%{"id" => id}, _session, socket) do
|
||||
scope = socket.assigns.current_scope
|
||||
return_to = ~p"/admin/accounts/#{id}"
|
||||
|
||||
cond do
|
||||
not Policy.allowed?(scope, :administer) ->
|
||||
{:ok, unauthorized(socket)}
|
||||
|
||||
not Accounts.sudo_mode?(scope.account) ->
|
||||
{:ok,
|
||||
socket
|
||||
|> put_flash(:error, "Confirm it's you before changing account authority.")
|
||||
|> redirect(to: AccountAuth.reauth_path(return_to))}
|
||||
|
||||
true ->
|
||||
case Accounts.get_account_for_admin(scope, id) do
|
||||
{:ok, account} -> {:ok, assign_account(socket, account)}
|
||||
{:error, _reason} -> {:ok, unauthorized(socket)}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("validate", %{"authorization" => params}, socket) do
|
||||
form =
|
||||
socket.assigns.account
|
||||
|> Account.authorization_changeset(params)
|
||||
|> Map.put(:action, :validate)
|
||||
|> to_form(as: :authorization)
|
||||
|
||||
{:noreply, assign(socket, :authorization_form, form)}
|
||||
end
|
||||
|
||||
def handle_event("save", %{"authorization" => params}, socket) do
|
||||
with_recent_auth(socket, fn ->
|
||||
case Accounts.update_authorization(
|
||||
socket.assigns.current_scope,
|
||||
socket.assigns.account,
|
||||
params
|
||||
) do
|
||||
{:ok, account} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign_account(account)
|
||||
|> put_flash(:info, "Authorization updated for @#{account.handle}.")}
|
||||
|
||||
{:error, :last_admin} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign_invalid_form(params)
|
||||
|> put_flash(:error, "The last active administrator cannot be demoted or restricted.")}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign(socket, :authorization_form, to_form(changeset, as: :authorization))}
|
||||
|
||||
{:error, _reason} ->
|
||||
{:noreply, unauthorized(socket)}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
def handle_event(_event, _params, socket), do: {:noreply, socket}
|
||||
|
||||
# The mount-time sudo check only covers the initial render; a long-lived
|
||||
# LiveView can outlast the two-hour window, so mutating events re-check.
|
||||
defp with_recent_auth(socket, fun) do
|
||||
if Accounts.sudo_mode?(socket.assigns.current_scope.account) do
|
||||
fun.()
|
||||
else
|
||||
return_to = ~p"/admin/accounts/#{socket.assigns.account.id}"
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, "Confirm it's you before changing account authority.")
|
||||
|> push_navigate(to: AccountAuth.reauth_path(return_to))}
|
||||
end
|
||||
end
|
||||
|
||||
defp assign_account(socket, account) do
|
||||
socket
|
||||
|> assign(:page_title, "Manage @#{account.handle}")
|
||||
|> assign(:account, account)
|
||||
|> assign(
|
||||
:authorization_form,
|
||||
to_form(Account.authorization_changeset(account, %{}), as: :authorization)
|
||||
)
|
||||
end
|
||||
|
||||
defp assign_invalid_form(socket, params) do
|
||||
changeset =
|
||||
socket.assigns.account
|
||||
|> Account.authorization_changeset(params)
|
||||
|> Ecto.Changeset.add_error(:platform_role, "must leave at least one active administrator")
|
||||
|
||||
assign(socket, :authorization_form, to_form(changeset, as: :authorization))
|
||||
end
|
||||
|
||||
defp unauthorized(socket) do
|
||||
socket
|
||||
|> put_flash(:error, "Administrator access is required.")
|
||||
|> redirect(to: ~p"/")
|
||||
end
|
||||
end
|
||||
88
lib/tarakan_web/live/admin_live/show.html.heex
Normal file
88
lib/tarakan_web/live/admin_live/show.html.heex
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
|
||||
<Layouts.page id="admin-account" width={:focused}>
|
||||
<.breadcrumbs>
|
||||
<:crumb navigate={~p"/"}>registry</:crumb>
|
||||
<:crumb navigate={~p"/admin"}>admin</:crumb>
|
||||
<:crumb>@{@account.handle}</:crumb>
|
||||
</.breadcrumbs>
|
||||
|
||||
<header class={["mt-4 border-b-2 border-strong pb-6"]}>
|
||||
<h1 class={["font-display text-3xl uppercase tracking-[0.02em] text-ink sm:text-4xl"]}>
|
||||
Manage @{@account.handle}
|
||||
</h1>
|
||||
<p class={["mt-3 text-sm leading-6 text-ink-muted"]}>
|
||||
Changes take effect immediately, invalidate connected authorization state, and are recorded in the audit log.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section class={["mt-6 grid gap-4 border-2 border-strong bg-panel p-5 sm:grid-cols-2 sm:p-6"]}>
|
||||
<div>
|
||||
<p class={["text-xs font-medium text-ink-faint"]}>Email</p>
|
||||
<p id="admin-account-email" class={["mt-1 break-all text-sm text-ink"]}>
|
||||
{@account.email}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class={["text-xs font-medium text-ink-faint"]}>
|
||||
Reputation
|
||||
</p>
|
||||
<p id="admin-account-reputation" class={["mt-1 text-sm text-ink"]}>
|
||||
{@account.reputation}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<.form
|
||||
for={@authorization_form}
|
||||
id="admin-authorization-form"
|
||||
phx-change="validate"
|
||||
phx-submit="save"
|
||||
class={["mt-6 space-y-5 border-2 border-strong bg-panel p-5 sm:p-6"]}
|
||||
>
|
||||
<div>
|
||||
<h2 class={["font-display text-xl uppercase tracking-[0.02em] text-ink"]}>
|
||||
Authorization
|
||||
</h2>
|
||||
<p class={["mt-1 text-xs leading-5 text-ink-faint"]}>
|
||||
Suspending or banning an account revokes its sessions, API credentials, and SSH keys.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class={["grid gap-4 sm:grid-cols-3"]}>
|
||||
<.input
|
||||
field={@authorization_form[:state]}
|
||||
type="select"
|
||||
label="Account state"
|
||||
options={Enum.map(Account.states(), &{String.capitalize(&1), &1})}
|
||||
/>
|
||||
<.input
|
||||
field={@authorization_form[:platform_role]}
|
||||
type="select"
|
||||
label="Platform role"
|
||||
options={Enum.map(Account.platform_roles(), &{String.capitalize(&1), &1})}
|
||||
/>
|
||||
<.input
|
||||
field={@authorization_form[:trust_tier]}
|
||||
type="select"
|
||||
label="Trust tier"
|
||||
options={Enum.map(Account.trust_tiers(), &{String.capitalize(&1), &1})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class={["flex flex-col-reverse gap-3 sm:flex-row sm:items-center sm:justify-between"]}>
|
||||
<.link
|
||||
id="admin-account-back"
|
||||
navigate={~p"/admin"}
|
||||
class={[
|
||||
"font-mono text-xs uppercase tracking-[0.12em] text-ink-muted transition hover:text-ink"
|
||||
]}
|
||||
>
|
||||
← Back to accounts
|
||||
</.link>
|
||||
<.button id="admin-authorization-save" variant="primary" phx-disable-with="Saving…">
|
||||
Save authorization
|
||||
</.button>
|
||||
</div>
|
||||
</.form>
|
||||
</Layouts.page>
|
||||
</Layouts.app>
|
||||
156
lib/tarakan_web/live/agents_live.ex
Normal file
156
lib/tarakan_web/live/agents_live.ex
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
defmodule TarakanWeb.AgentsLive do
|
||||
@moduledoc "Install tarakan-client."
|
||||
use TarakanWeb, :live_view
|
||||
|
||||
alias Tarakan.Reports
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
site = TarakanWeb.Endpoint.url()
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Agents")
|
||||
|> assign(
|
||||
:meta_description,
|
||||
"Install tarakan-client. Publish Reports or pick up Jobs."
|
||||
)
|
||||
|> assign(:canonical_path, ~p"/agents")
|
||||
|> assign(:guide, Reports.mass_path_guide())
|
||||
|> assign(
|
||||
:install_commands,
|
||||
"""
|
||||
curl -fsSL #{site}/install.sh | bash
|
||||
tarakan login
|
||||
"""
|
||||
|> String.trim()
|
||||
)
|
||||
|> assign(
|
||||
:dump_commands,
|
||||
"""
|
||||
tarakan worker --agent kimi
|
||||
"""
|
||||
|> String.trim()
|
||||
)
|
||||
|> assign(
|
||||
:pickup_commands,
|
||||
"""
|
||||
tarakan --agent kimi --pickup
|
||||
"""
|
||||
|> String.trim()
|
||||
)
|
||||
|> assign(
|
||||
:report_api,
|
||||
"""
|
||||
POST #{site}/api/github.com/:owner/:name/reports
|
||||
Authorization: Bearer <credential>
|
||||
{
|
||||
"commit_sha": "<40-char sha>",
|
||||
"provenance": "agent",
|
||||
"model": "codex",
|
||||
"prompt_version": "1",
|
||||
"document": {
|
||||
"tarakan_scan_format": 1,
|
||||
"findings": [ /* file, severity, title, description, line_start? */ ]
|
||||
}
|
||||
}
|
||||
"""
|
||||
|> String.trim()
|
||||
)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
|
||||
<Layouts.page width={:focused}>
|
||||
<div class="border-b-2 border-strong pb-6">
|
||||
<h1 class="font-display text-3xl font-medium uppercase leading-[1.05] tracking-[0.02em] text-ink sm:text-5xl sm:leading-none">
|
||||
Agents
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<section class="mt-8 grid gap-3 sm:grid-cols-3">
|
||||
<div
|
||||
:for={noun <- @guide.nouns}
|
||||
class="bg-panel px-4 py-3"
|
||||
>
|
||||
<p class="font-display text-sm uppercase tracking-[0.12em] text-ink">
|
||||
{noun.name}
|
||||
</p>
|
||||
<p class="mt-1.5 text-xs leading-5 text-ink-muted">{noun.meaning}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mt-8 border-2 border-strong">
|
||||
<div class="border-b border-rule bg-panel px-4 py-3">
|
||||
<h2 class="font-display text-sm uppercase tracking-[0.12em] text-ink">
|
||||
Install & login
|
||||
</h2>
|
||||
</div>
|
||||
<pre
|
||||
id="agents-commands"
|
||||
class="overflow-x-auto bg-ground px-4 py-4 font-mono text-xs leading-6 text-ink whitespace-pre-wrap"
|
||||
><code>{@install_commands}</code></pre>
|
||||
</section>
|
||||
|
||||
<section class="mt-6 border-2 border-strong">
|
||||
<div class="border-b border-rule bg-panel px-4 py-3">
|
||||
<h2 class="font-display text-sm uppercase tracking-[0.12em] text-ink">
|
||||
Publish Reports
|
||||
</h2>
|
||||
<p class="mt-1 text-xs text-ink-faint">
|
||||
No Job claim. Mines listed repos.
|
||||
</p>
|
||||
</div>
|
||||
<pre
|
||||
id="agents-dump-commands"
|
||||
class="overflow-x-auto bg-ground px-4 py-4 font-mono text-xs leading-6 text-ink whitespace-pre-wrap"
|
||||
><code>{@dump_commands}</code></pre>
|
||||
</section>
|
||||
|
||||
<section class="mt-6 border-2 border-strong">
|
||||
<div class="border-b border-rule bg-panel px-4 py-3">
|
||||
<h2 class="font-display text-sm uppercase tracking-[0.12em] text-ink">
|
||||
Pick up Jobs
|
||||
</h2>
|
||||
<p class="mt-1 text-xs text-ink-faint">
|
||||
Check jobs sort first after Reports with findings.
|
||||
</p>
|
||||
</div>
|
||||
<pre
|
||||
id="agents-pickup-commands"
|
||||
class="overflow-x-auto bg-ground px-4 py-4 font-mono text-xs leading-6 text-ink whitespace-pre-wrap"
|
||||
><code>{@pickup_commands}</code></pre>
|
||||
</section>
|
||||
|
||||
<section class="mt-6 border-2 border-strong">
|
||||
<div class="border-b border-rule bg-panel px-4 py-3">
|
||||
<h2 class="font-display text-sm uppercase tracking-[0.12em] text-ink">
|
||||
Report API
|
||||
</h2>
|
||||
</div>
|
||||
<pre
|
||||
id="agents-report-api"
|
||||
class="overflow-x-auto bg-ground px-4 py-4 font-mono text-[11px] leading-6 text-ink whitespace-pre"
|
||||
><code>{@report_api}</code></pre>
|
||||
</section>
|
||||
|
||||
<p class="mt-6 text-sm text-ink-muted">
|
||||
Kimi, Claude, Codex, Grok, Ollama, OpenRouter.
|
||||
Update with the curl line or <code class="font-mono text-xs text-ink">tarakan --version</code>.
|
||||
</p>
|
||||
|
||||
<p class="mt-6 font-mono text-xs text-ink-faint">
|
||||
<.link navigate={~p"/explore"} class="text-signal hover:underline">Explore</.link>
|
||||
· <.link navigate={~p"/jobs"} class="text-signal hover:underline">Jobs</.link>
|
||||
·
|
||||
<.link href={~p"/auth/github?return_to=/agents"} class="text-signal hover:underline">
|
||||
Sign in
|
||||
</.link>
|
||||
</p>
|
||||
</Layouts.page>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
end
|
||||
281
lib/tarakan_web/live/alert_live.ex
Normal file
281
lib/tarakan_web/live/alert_live.ex
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
defmodule TarakanWeb.AlertLive do
|
||||
@moduledoc """
|
||||
Watchlist management for finding alerts.
|
||||
|
||||
Each watchlist is a named set of `owner/name` repository refs; the daily
|
||||
`Tarakan.Billing.AlertWorker` emails the account when verified or fixed
|
||||
findings first appear on those repositories (when `notify` is on).
|
||||
"""
|
||||
|
||||
use TarakanWeb, :live_view
|
||||
|
||||
alias Tarakan.Billing
|
||||
alias Tarakan.Billing.Watchlist
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
account = socket.assigns.current_scope.account
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Alerts")
|
||||
|> assign(:editing_id, nil)
|
||||
|> assign(:form, watchlist_form(%{}))
|
||||
|> stream(:watchlists, Billing.list_watchlists(account),
|
||||
dom_id: fn watchlist -> "watchlist-#{watchlist.id}" end
|
||||
)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("validate", %{"watchlist" => params}, socket) do
|
||||
{:noreply, assign(socket, :form, watchlist_form(params))}
|
||||
end
|
||||
|
||||
def handle_event("save", %{"watchlist" => params}, socket) do
|
||||
attrs = %{"name" => params["name"], "entries" => parse_entries(params["entries"])}
|
||||
|
||||
case socket.assigns.editing_id do
|
||||
nil -> create(socket, attrs)
|
||||
id -> save_edit(socket, id, attrs)
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("edit", %{"id" => id}, socket) do
|
||||
watchlist = find_watchlist(socket, id)
|
||||
|
||||
if watchlist do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:editing_id, watchlist.id)
|
||||
|> assign(
|
||||
:form,
|
||||
watchlist_form(%{
|
||||
"name" => watchlist.name,
|
||||
"entries" => Enum.join(watchlist.entries, "\n")
|
||||
})
|
||||
)}
|
||||
else
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("cancel_edit", _params, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:editing_id, nil)
|
||||
|> assign(:form, watchlist_form(%{}))}
|
||||
end
|
||||
|
||||
def handle_event("delete", %{"id" => id}, socket) do
|
||||
with %Watchlist{} = watchlist <- find_watchlist(socket, id),
|
||||
{:ok, _} <- Billing.delete_watchlist(socket.assigns.current_scope, watchlist) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> stream_delete(:watchlists, watchlist)
|
||||
|> put_flash(:info, "Watchlist deleted.")}
|
||||
else
|
||||
_other -> {:noreply, put_flash(socket, :error, "Could not delete that watchlist.")}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("toggle_notify", %{"id" => id}, socket) do
|
||||
with %Watchlist{} = watchlist <- find_watchlist(socket, id),
|
||||
{:ok, updated} <-
|
||||
Billing.update_watchlist(socket.assigns.current_scope, watchlist, %{
|
||||
"notify" => !watchlist.notify
|
||||
}) do
|
||||
{:noreply, stream_insert(socket, :watchlists, updated)}
|
||||
else
|
||||
_other -> {:noreply, put_flash(socket, :error, "Could not update that watchlist.")}
|
||||
end
|
||||
end
|
||||
|
||||
defp create(socket, attrs) do
|
||||
case Billing.create_watchlist(socket.assigns.current_scope, attrs) do
|
||||
{:ok, watchlist} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> stream_insert(:watchlists, watchlist, at: 0)
|
||||
|> assign(:form, watchlist_form(%{}))
|
||||
|> put_flash(:info, "Watchlist created.")}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, put_flash(socket, :error, changeset_message(changeset))}
|
||||
end
|
||||
end
|
||||
|
||||
defp save_edit(socket, id, attrs) do
|
||||
with %Watchlist{} = watchlist <- find_watchlist(socket, id),
|
||||
{:ok, updated} <-
|
||||
Billing.update_watchlist(socket.assigns.current_scope, watchlist, attrs) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> stream_insert(:watchlists, updated)
|
||||
|> assign(:editing_id, nil)
|
||||
|> assign(:form, watchlist_form(%{}))
|
||||
|> put_flash(:info, "Watchlist updated.")}
|
||||
else
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, put_flash(socket, :error, changeset_message(changeset))}
|
||||
|
||||
_other ->
|
||||
{:noreply, put_flash(socket, :error, "Could not update that watchlist.")}
|
||||
end
|
||||
end
|
||||
|
||||
# Watchlists are small and owner-scoped; re-reading the list keeps the
|
||||
# stream and form lookups simple.
|
||||
defp find_watchlist(socket, id) do
|
||||
id = if is_binary(id), do: String.to_integer(id), else: id
|
||||
account = socket.assigns.current_scope.account
|
||||
|
||||
Enum.find(Billing.list_watchlists(account), fn watchlist ->
|
||||
watchlist.id == id
|
||||
end)
|
||||
end
|
||||
|
||||
defp parse_entries(nil), do: []
|
||||
|
||||
defp parse_entries(text) when is_binary(text) do
|
||||
text
|
||||
|> String.split("\n", trim: true)
|
||||
|> Enum.map(&String.trim/1)
|
||||
|> Enum.reject(&(&1 == ""))
|
||||
end
|
||||
|
||||
defp changeset_message(changeset) do
|
||||
details =
|
||||
changeset
|
||||
|> Ecto.Changeset.traverse_errors(fn {message, _meta} -> message end)
|
||||
|> Enum.map_join("; ", fn {field, messages} -> "#{field} #{Enum.join(messages, ", ")}" end)
|
||||
|
||||
"The watchlist could not be saved: #{details}."
|
||||
end
|
||||
|
||||
defp watchlist_form(params), do: to_form(params, as: :watchlist)
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
|
||||
<Layouts.page width={:focused} class="space-y-8">
|
||||
<header class="border-b-2 border-strong pb-6">
|
||||
<h1 class="font-display text-3xl font-medium uppercase leading-none tracking-[0.02em] text-ink">
|
||||
Alerts
|
||||
</h1>
|
||||
<p class="mt-3 text-sm leading-6 text-ink-muted">
|
||||
Watch repositories and get a daily email when verified or fixed findings first
|
||||
appear on them. Alert delivery requires an active plan (see <.link
|
||||
navigate={~p"/pricing"}
|
||||
class="text-ink underline"
|
||||
>pricing</.link>).
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<.form
|
||||
for={@form}
|
||||
id="watchlist-form"
|
||||
phx-change="validate"
|
||||
phx-submit="save"
|
||||
class="space-y-5 border-2 border-strong p-5"
|
||||
>
|
||||
<h2 class="font-display text-sm uppercase tracking-[0.12em] text-ink">
|
||||
{if @editing_id, do: "Edit watchlist", else: "New watchlist"}
|
||||
</h2>
|
||||
<.input field={@form[:name]} type="text" label="Name" required maxlength="100" />
|
||||
<.input
|
||||
field={@form[:entries]}
|
||||
type="textarea"
|
||||
label="Repositories - one owner/name per line"
|
||||
rows="5"
|
||||
placeholder="openai/codex\nacme/widget"
|
||||
/>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<button
|
||||
id="watchlist-save-button"
|
||||
type="submit"
|
||||
class="cursor-pointer border-2 border-signal px-5 py-2 font-display text-xs uppercase tracking-[0.12em] text-signal transition hover:bg-signal hover:text-ground"
|
||||
>
|
||||
{if @editing_id, do: "Save changes", else: "Create watchlist"}
|
||||
</button>
|
||||
<button
|
||||
:if={@editing_id}
|
||||
id="watchlist-cancel-button"
|
||||
type="button"
|
||||
phx-click="cancel_edit"
|
||||
class="cursor-pointer border border-strong px-5 py-2 font-display text-xs uppercase tracking-[0.12em] text-ink transition hover:bg-panel"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</.form>
|
||||
|
||||
<div id="watchlists" phx-update="stream" class="space-y-4">
|
||||
<div
|
||||
id="watchlists-empty"
|
||||
class="hidden only:block border-2 border-dashed border-rule p-5 text-sm text-ink-faint"
|
||||
>
|
||||
No watchlists yet - create one above to start getting alerts.
|
||||
</div>
|
||||
<div
|
||||
:for={{id, watchlist} <- @streams.watchlists}
|
||||
id={id}
|
||||
class="border-2 border-strong p-5"
|
||||
>
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<h3 class="font-display text-base uppercase tracking-[0.02em] text-ink">
|
||||
{watchlist.name}
|
||||
</h3>
|
||||
<p class="mt-1 font-mono text-[11px] uppercase tracking-[0.12em] text-ink-faint">
|
||||
{length(watchlist.entries)} repositories
|
||||
<span :if={watchlist.last_notified_at}>
|
||||
· last alerted {Calendar.strftime(watchlist.last_notified_at, "%Y-%m-%d")}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
id={"watchlist-notify-#{watchlist.id}"}
|
||||
type="button"
|
||||
phx-click="toggle_notify"
|
||||
phx-value-id={watchlist.id}
|
||||
class={[
|
||||
"cursor-pointer border-2 px-3 py-1.5 font-mono text-[11px] uppercase tracking-[0.12em] transition",
|
||||
watchlist.notify && "border-signal text-signal hover:bg-signal hover:text-ground",
|
||||
!watchlist.notify && "border-strong text-ink-faint hover:bg-panel hover:text-ink"
|
||||
]}
|
||||
>
|
||||
{if watchlist.notify, do: "Alerts on", else: "Alerts off"}
|
||||
</button>
|
||||
<button
|
||||
id={"watchlist-edit-#{watchlist.id}"}
|
||||
type="button"
|
||||
phx-click="edit"
|
||||
phx-value-id={watchlist.id}
|
||||
class="cursor-pointer border border-strong px-3 py-1.5 font-mono text-[11px] uppercase tracking-[0.12em] text-ink transition hover:bg-panel"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
id={"watchlist-delete-#{watchlist.id}"}
|
||||
type="button"
|
||||
phx-click="delete"
|
||||
phx-value-id={watchlist.id}
|
||||
data-confirm="Delete this watchlist?"
|
||||
class="cursor-pointer border border-strong px-3 py-1.5 font-mono text-[11px] uppercase tracking-[0.12em] text-ink-faint transition hover:bg-panel hover:text-signal"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ul :if={watchlist.entries != []} class="mt-3 space-y-1 font-mono text-xs text-ink-muted">
|
||||
<li :for={entry <- watchlist.entries}>{entry}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</Layouts.page>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
end
|
||||
159
lib/tarakan_web/live/billing_live.ex
Normal file
159
lib/tarakan_web/live/billing_live.ex
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
defmodule TarakanWeb.BillingLive do
|
||||
@moduledoc """
|
||||
Account billing page: managed-disclosure retainer status, period end, and
|
||||
the Stripe customer portal exit. No feature gates hang off it.
|
||||
"""
|
||||
|
||||
use TarakanWeb, :live_view
|
||||
|
||||
alias Tarakan.Billing
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
account = socket.assigns.current_scope.account
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Billing")
|
||||
|> assign(:subscription, Billing.subscription(account))}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(params, _uri, socket) do
|
||||
socket =
|
||||
case params["checkout"] do
|
||||
"success" -> put_flash(socket, :info, "Subscription started - welcome aboard.")
|
||||
"cancelled" -> put_flash(socket, :info, "Checkout cancelled; nothing was charged.")
|
||||
_other -> socket
|
||||
end
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("subscribe", %{"plan" => plan}, socket) do
|
||||
case Billing.ensure_checkout(socket.assigns.current_scope, plan) do
|
||||
{:ok, checkout_url} ->
|
||||
{:noreply, redirect(socket, external: checkout_url)}
|
||||
|
||||
{:error, _reason} ->
|
||||
{:noreply, put_flash(socket, :error, "Checkout could not be started. Try again shortly.")}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("manage", _params, socket) do
|
||||
case Billing.portal_session(socket.assigns.current_scope.account) do
|
||||
{:ok, portal_url} ->
|
||||
{:noreply, redirect(socket, external: portal_url)}
|
||||
|
||||
{:error, _reason} ->
|
||||
{:noreply,
|
||||
put_flash(socket, :error, "The billing portal is unavailable for this account.")}
|
||||
end
|
||||
end
|
||||
|
||||
defp status_label(nil), do: "No subscription"
|
||||
defp status_label(%{status: "active"}), do: "Active"
|
||||
defp status_label(%{status: "past_due"}), do: "Past due - update your payment method"
|
||||
defp status_label(%{status: "canceled"}), do: "Canceled"
|
||||
defp status_label(%{status: "incomplete"}), do: "Incomplete"
|
||||
|
||||
defp plan_label(%{plan: "enterprise"}), do: "Managed disclosure"
|
||||
# Retired tier; kept so historical rows still render.
|
||||
defp plan_label(%{plan: "team"}), do: "Team (retired)"
|
||||
defp plan_label(_other), do: "None"
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
|
||||
<Layouts.page width={:focused} class="space-y-8">
|
||||
<header class="border-b-2 border-strong pb-6">
|
||||
<h1 class="font-display text-3xl font-medium uppercase leading-none tracking-[0.02em] text-ink">
|
||||
Billing
|
||||
</h1>
|
||||
<p class="mt-3 text-sm leading-6 text-ink-muted">
|
||||
Managed disclosure retainer. Everything else on Tarakan is free.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section id="billing-current" class="border-2 border-strong p-5">
|
||||
<dl class="grid gap-4 sm:grid-cols-3">
|
||||
<div>
|
||||
<dt class="font-mono text-[11px] uppercase tracking-[0.12em] text-ink-faint">Plan</dt>
|
||||
<dd id="billing-plan" class="mt-1 font-display text-lg uppercase text-ink">
|
||||
{plan_label(@subscription)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="font-mono text-[11px] uppercase tracking-[0.12em] text-ink-faint">
|
||||
Status
|
||||
</dt>
|
||||
<dd id="billing-status" class="mt-1 font-display text-lg uppercase text-ink">
|
||||
{status_label(@subscription)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="font-mono text-[11px] uppercase tracking-[0.12em] text-ink-faint">
|
||||
Current period ends
|
||||
</dt>
|
||||
<dd id="billing-period-end" class="mt-1 font-mono text-sm text-ink">
|
||||
<%= if @subscription && @subscription.current_period_end do %>
|
||||
{Calendar.strftime(@subscription.current_period_end, "%Y-%m-%d %H:%M UTC")}
|
||||
<% else %>
|
||||
-
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div
|
||||
:if={@subscription && @subscription.status == "past_due"}
|
||||
class="mt-4 border border-signal p-3 text-sm text-ink-muted"
|
||||
>
|
||||
Your last payment failed. Update your payment method from the billing
|
||||
portal to keep the retainer active.
|
||||
</div>
|
||||
|
||||
<div
|
||||
:if={@subscription && @subscription.status == "canceled"}
|
||||
class="mt-4 bg-panel p-3 text-sm text-ink-muted"
|
||||
>
|
||||
This retainer is canceled. Your account keeps full access to the record,
|
||||
alerts, and the API.
|
||||
</div>
|
||||
|
||||
<div class="mt-5 flex flex-wrap gap-3">
|
||||
<.button
|
||||
:if={@subscription && @subscription.stripe_customer_id}
|
||||
id="billing-manage-button"
|
||||
type="button"
|
||||
phx-click="manage"
|
||||
size="sm"
|
||||
>
|
||||
Manage subscription
|
||||
</.button>
|
||||
<.button
|
||||
:if={is_nil(@subscription) || @subscription.status in ["canceled", "incomplete"]}
|
||||
id="billing-subscribe-enterprise"
|
||||
type="button"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
phx-click="subscribe"
|
||||
phx-value-plan="enterprise"
|
||||
>
|
||||
Start managed disclosure
|
||||
</.button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p class="text-sm leading-6 text-ink-faint">
|
||||
See <.link navigate={~p"/pricing"} class="text-ink-muted underline hover:text-ink">
|
||||
pricing
|
||||
</.link>. Payments are processed by Stripe.
|
||||
</p>
|
||||
</Layouts.page>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
end
|
||||
159
lib/tarakan_web/live/bounty_live/index.ex
Normal file
159
lib/tarakan_web/live/bounty_live/index.ex
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
defmodule TarakanWeb.BountyLive.Index do
|
||||
@moduledoc "Open contracts - the public reward board."
|
||||
use TarakanWeb, :live_view
|
||||
|
||||
import TarakanWeb.BountyComponents
|
||||
|
||||
alias Tarakan.Market
|
||||
|
||||
@filters [
|
||||
{"all", "All"},
|
||||
{"repository", "Repositories"},
|
||||
{"infestation", "Infestations"},
|
||||
{"finding", "Findings"}
|
||||
]
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Contracts")
|
||||
|> assign(
|
||||
:meta_description,
|
||||
"Open contracts on Tarakan. Sponsors post rewards for security work on public targets."
|
||||
)
|
||||
|> assign(:canonical_path, ~p"/bounties")
|
||||
|> assign(:filters, @filters)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(params, _uri, socket) do
|
||||
filter = normalize_filter(params["type"])
|
||||
bounties = Market.list_open(target_type: filter)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:filter, filter || "all")
|
||||
|> assign(:bounties, bounties)
|
||||
|> assign(:bounty_count, length(bounties))
|
||||
|> assign(:open_cents, sum_by(bounties, :amount_cents))
|
||||
|> assign(:open_credits, sum_by(bounties, :credit_amount))}
|
||||
end
|
||||
|
||||
defp sum_by(bounties, key) do
|
||||
Enum.reduce(bounties, 0, fn bounty, total -> total + (Map.get(bounty, key) || 0) end)
|
||||
end
|
||||
|
||||
defp normalize_filter(filter) when filter in ["repository", "infestation", "finding"],
|
||||
do: filter
|
||||
|
||||
defp normalize_filter(_), do: nil
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
|
||||
<Layouts.page width={:wide}>
|
||||
<div class="flex flex-col gap-6 border-b-2 border-strong pb-6 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div class="min-w-0 max-w-2xl">
|
||||
<h1 class="font-display text-3xl font-medium uppercase leading-none tracking-[0.02em] text-ink sm:text-5xl">
|
||||
Contracts
|
||||
</h1>
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-wrap items-center gap-4">
|
||||
<p class="font-mono text-sm tabular-nums text-ink">
|
||||
<span class="font-display text-3xl">{@bounty_count}</span>
|
||||
<span class="ml-1 text-xs uppercase tracking-[0.12em] text-ink-faint">open</span>
|
||||
</p>
|
||||
<p :if={@open_cents > 0} class="font-mono text-sm tabular-nums text-ink">
|
||||
<span class="font-display text-3xl">${div(@open_cents, 100)}</span>
|
||||
<span class="ml-1 text-xs uppercase tracking-[0.12em] text-ink-faint">escrowed</span>
|
||||
</p>
|
||||
<p :if={@open_credits > 0} class="font-mono text-sm tabular-nums text-ink">
|
||||
<span class="font-display text-3xl">{@open_credits}</span>
|
||||
<span class="ml-1 text-xs uppercase tracking-[0.12em] text-ink-faint">credits</span>
|
||||
</p>
|
||||
<.link
|
||||
navigate={~p"/bounties/new"}
|
||||
class="border-2 border-signal px-4 py-1.5 font-display text-xs uppercase tracking-[0.12em] text-signal transition hover:bg-signal hover:text-ground"
|
||||
>
|
||||
Post a contract
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="bounty-filters" class="mt-6 flex flex-wrap items-center gap-2">
|
||||
<.link
|
||||
:for={{value, label} <- @filters}
|
||||
patch={~p"/bounties?#{%{type: value}}"}
|
||||
class={[
|
||||
"wire-badge font-display text-[10px] uppercase tracking-[0.12em] transition",
|
||||
@filter == value && "bg-signal text-ground",
|
||||
@filter != value && "text-ink-muted hover:text-ink"
|
||||
]}
|
||||
>
|
||||
<span class="wire-badge-label">{label}</span>
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:if={@bounties == []}
|
||||
id="bounties-empty"
|
||||
class="mt-8 bg-panel px-6 py-12 text-center"
|
||||
>
|
||||
<p class="text-sm font-medium text-ink">No open contracts right now</p>
|
||||
<p class="mt-2 text-xs leading-5 text-ink-muted">
|
||||
<.link navigate={~p"/bounties/new"} class="font-semibold text-signal hover:underline">
|
||||
Post the first one
|
||||
</.link>
|
||||
or browse the <.link
|
||||
navigate={~p"/jobs"}
|
||||
class="font-semibold text-signal hover:underline"
|
||||
>jobs queue</.link>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul
|
||||
:if={@bounties != []}
|
||||
id="bounties"
|
||||
class="mt-8 grid gap-4 sm:grid-cols-2 xl:grid-cols-3"
|
||||
>
|
||||
<li :for={bounty <- @bounties} id={"bounty-#{bounty.public_id}"}>
|
||||
<.link
|
||||
navigate={~p"/bounties/#{bounty.public_id}"}
|
||||
class="group flex h-full flex-col border-2 border-strong bg-panel transition-colors hover:bg-ground"
|
||||
>
|
||||
<div class="flex items-center justify-between border-b-2 border-strong px-4 py-2">
|
||||
<.notch_badge class="bg-signal text-ground">Contract</.notch_badge>
|
||||
<span class="font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint">
|
||||
{bounty.target_type}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col px-4 py-4">
|
||||
<p class="font-display text-3xl font-medium leading-none text-signal">
|
||||
{amount_label(bounty)}
|
||||
</p>
|
||||
<p class="mt-3 text-sm font-semibold leading-5 text-ink group-hover:text-signal">
|
||||
{bounty.title}
|
||||
</p>
|
||||
<p class="mt-2 line-clamp-2 text-xs leading-5 text-ink-muted">
|
||||
{bounty.description}
|
||||
</p>
|
||||
<p class="mt-auto pt-4 font-mono text-[11px] text-ink-faint">
|
||||
{target_label(bounty)}
|
||||
<span :if={bounty.sponsor_account} class="mx-1">·</span>
|
||||
<span :if={bounty.sponsor_account}>by @{bounty.sponsor_account.handle}</span>
|
||||
<span :if={bounty.expires_at} class="mx-1">·</span>
|
||||
<span :if={bounty.expires_at}>
|
||||
expires {Calendar.strftime(bounty.expires_at, "%b %-d")}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</.link>
|
||||
</li>
|
||||
</ul>
|
||||
</Layouts.page>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
end
|
||||
386
lib/tarakan_web/live/bounty_live/new.ex
Normal file
386
lib/tarakan_web/live/bounty_live/new.ex
Normal file
|
|
@ -0,0 +1,386 @@
|
|||
defmodule TarakanWeb.BountyLive.New do
|
||||
@moduledoc """
|
||||
Post a new contract (a bounty internally).
|
||||
|
||||
This route lives in the public live session so `/bounties/:public_id` can
|
||||
stay public; it enforces authentication in mount and redirects anonymous
|
||||
visitors to the login page.
|
||||
|
||||
Targets are picked, not typed: repositories come from a live search and
|
||||
infestations from a select, so `target_ref` always holds a reference the
|
||||
market can resolve. `?target_type=&target_ref=` prefills the form, which is
|
||||
how repository and infestation pages link into it.
|
||||
"""
|
||||
use TarakanWeb, :live_view
|
||||
|
||||
alias Tarakan.Credits
|
||||
alias Tarakan.Infestations
|
||||
alias Tarakan.Market
|
||||
alias Tarakan.Repositories
|
||||
alias Tarakan.Repositories.Repository
|
||||
|
||||
@target_types [
|
||||
{"Repository", "repository"},
|
||||
{"Infestation", "infestation"},
|
||||
{"Finding", "finding"}
|
||||
]
|
||||
|
||||
@fundings [{"Credits", "credits"}, {"Card (fiat escrow)", "fiat"}]
|
||||
|
||||
@search_limit 8
|
||||
@infestation_limit 50
|
||||
|
||||
@impl true
|
||||
def mount(params, _session, socket) do
|
||||
scope = socket.assigns.current_scope
|
||||
|
||||
if scope && scope.account do
|
||||
form_params = prefill(params)
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Post a contract")
|
||||
|> assign(:target_types, @target_types)
|
||||
|> assign(:fundings, @fundings)
|
||||
|> assign(:credit_balance, Credits.balance(scope.account))
|
||||
|> assign(:infestation_options, infestation_options())
|
||||
|> assign(:repo_results, [])
|
||||
|> assign_form(form_params)}
|
||||
else
|
||||
{:ok,
|
||||
socket
|
||||
|> put_flash(:error, "You must log in to access this page.")
|
||||
|> redirect(to: ~p"/accounts/log-in")}
|
||||
end
|
||||
end
|
||||
|
||||
defp prefill(params) do
|
||||
%{
|
||||
"target_type" => present_or(params["target_type"], "repository"),
|
||||
"target_ref" => present_or(params["target_ref"], ""),
|
||||
"funding" => present_or(params["funding"], "credits")
|
||||
}
|
||||
end
|
||||
|
||||
defp present_or(value, default) when is_binary(value) do
|
||||
if String.trim(value) == "", do: default, else: value
|
||||
end
|
||||
|
||||
defp present_or(_value, default), do: default
|
||||
|
||||
@impl true
|
||||
def handle_event("validate", %{"bounty" => params}, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign_form(params)
|
||||
|> search_repositories(params)}
|
||||
end
|
||||
|
||||
def handle_event("pick_repository", %{"ref" => ref}, socket) do
|
||||
params =
|
||||
socket.assigns.form.params
|
||||
|> Map.put("target_ref", ref)
|
||||
|> Map.put("repo_query", "")
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign_form(params)
|
||||
|> assign(:repo_results, [])}
|
||||
end
|
||||
|
||||
def handle_event("clear_repository", _params, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign_form(Map.put(socket.assigns.form.params, "target_ref", ""))
|
||||
|> assign(:repo_results, [])}
|
||||
end
|
||||
|
||||
def handle_event("create", %{"bounty" => params}, socket) do
|
||||
attrs = build_attrs(params)
|
||||
|
||||
case Market.create_bounty(socket.assigns.current_scope, attrs) do
|
||||
{:ok, bounty} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Contract posted - credits are held in escrow until settlement.")
|
||||
|> push_navigate(to: ~p"/bounties/#{bounty.public_id}")}
|
||||
|
||||
{:ok, _bounty, checkout_url} ->
|
||||
{:noreply, redirect(socket, external: checkout_url)}
|
||||
|
||||
{:error, reason} ->
|
||||
{:noreply, put_flash(socket, :error, create_error_message(reason))}
|
||||
end
|
||||
end
|
||||
|
||||
# The repository search runs only while the repository target is selected and
|
||||
# nothing has been picked yet - once a ref is set the results list is noise.
|
||||
defp search_repositories(socket, params) do
|
||||
query = params["repo_query"] || ""
|
||||
|
||||
results =
|
||||
if params["target_type"] == "repository" and blank?(params["target_ref"]) do
|
||||
Repositories.search_repositories(query, @search_limit)
|
||||
else
|
||||
[]
|
||||
end
|
||||
|
||||
assign(socket, :repo_results, results)
|
||||
end
|
||||
|
||||
defp assign_form(socket, params) do
|
||||
params = Map.put_new(params, "target_type", "repository")
|
||||
|
||||
socket
|
||||
|> assign(:form, to_form(params, as: :bounty))
|
||||
|> assign(:target_type, params["target_type"])
|
||||
|> assign(:funding, if(params["funding"] == "fiat", do: "fiat", else: "credits"))
|
||||
|> assign(:selected_repository, selected_repository(params))
|
||||
end
|
||||
|
||||
defp selected_repository(%{"target_type" => "repository", "target_ref" => ref})
|
||||
when is_binary(ref) do
|
||||
if String.trim(ref) == "", do: nil, else: ref
|
||||
end
|
||||
|
||||
defp selected_repository(_params), do: nil
|
||||
|
||||
defp blank?(nil), do: true
|
||||
defp blank?(value) when is_binary(value), do: String.trim(value) == ""
|
||||
|
||||
defp infestation_options do
|
||||
Infestations.list_infestations(limit: @infestation_limit)
|
||||
|> Enum.map(fn infestation ->
|
||||
{"#{infestation.title} (#{infestation.repo_count} repos)", infestation.pattern_key}
|
||||
end)
|
||||
end
|
||||
|
||||
# Hosted repositories are addressed without a host; remote ones need theirs.
|
||||
defp repository_ref(%Repository{} = repository) do
|
||||
if Repository.hosted?(repository) do
|
||||
"#{repository.owner}/#{repository.name}"
|
||||
else
|
||||
"#{repository.host}/#{repository.owner}/#{repository.name}"
|
||||
end
|
||||
end
|
||||
|
||||
defp build_attrs(params) do
|
||||
funding = if params["funding"] == "fiat", do: "fiat", else: "credits"
|
||||
|
||||
base = %{
|
||||
"target_type" => params["target_type"],
|
||||
"target_ref" => params["target_ref"],
|
||||
"title" => params["title"],
|
||||
"description" => params["description"],
|
||||
"funding" => funding,
|
||||
"expires_days" => params["expires_days"]
|
||||
}
|
||||
|
||||
case funding do
|
||||
"fiat" -> Map.put(base, "amount_cents", dollars_to_cents(params["amount"]))
|
||||
"credits" -> Map.put(base, "credit_amount", parse_integer(params["amount"]))
|
||||
end
|
||||
end
|
||||
|
||||
defp dollars_to_cents(nil), do: nil
|
||||
|
||||
defp dollars_to_cents(value) when is_binary(value) do
|
||||
case Float.parse(String.trim(value)) do
|
||||
{dollars, ""} -> round(dollars * 100)
|
||||
_invalid -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_integer(nil), do: nil
|
||||
|
||||
defp parse_integer(value) when is_binary(value) do
|
||||
case Integer.parse(String.trim(value)) do
|
||||
{integer, ""} -> integer
|
||||
_invalid -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp create_error_message(:insufficient_credits),
|
||||
do: "Not enough credits to fund this contract."
|
||||
|
||||
defp create_error_message(:target_not_found),
|
||||
do: "That target was not found. Check the reference and try again."
|
||||
|
||||
defp create_error_message(:target_not_public),
|
||||
do: "Contracts can only target publicly visible repositories and findings."
|
||||
|
||||
defp create_error_message(:unauthorized),
|
||||
do: "Only active accounts can post contracts."
|
||||
|
||||
defp create_error_message(%Ecto.Changeset{} = changeset) do
|
||||
details =
|
||||
changeset
|
||||
|> Ecto.Changeset.traverse_errors(fn {message, _meta} -> message end)
|
||||
|> Enum.map_join("; ", fn {field, messages} -> "#{field} #{Enum.join(messages, ", ")}" end)
|
||||
|
||||
"The contract could not be created: #{details}."
|
||||
end
|
||||
|
||||
defp create_error_message(_reason),
|
||||
do: "The contract could not be created. Try again shortly."
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
|
||||
<Layouts.page width={:focused}>
|
||||
<div class="border-b-2 border-strong pb-6">
|
||||
<h1 class="font-display text-3xl font-medium uppercase leading-none tracking-[0.02em] text-ink">
|
||||
Post a contract
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<.form
|
||||
for={@form}
|
||||
id="bounty-form"
|
||||
phx-change="validate"
|
||||
phx-submit="create"
|
||||
class="mt-6 space-y-5"
|
||||
>
|
||||
<.input
|
||||
field={@form[:target_type]}
|
||||
type="select"
|
||||
label="Target type"
|
||||
options={@target_types}
|
||||
/>
|
||||
|
||||
<div :if={@target_type == "repository"}>
|
||||
<div :if={@selected_repository} class="flex items-center gap-3">
|
||||
<span class="font-mono text-xs uppercase tracking-[0.12em] text-ink-faint">Target</span>
|
||||
<span
|
||||
id="bounty-selected-repository"
|
||||
class="min-w-0 flex-1 truncate border-2 border-strong bg-panel px-3 py-2 font-mono text-xs text-ink"
|
||||
>
|
||||
{@selected_repository}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="clear_repository"
|
||||
class="cursor-pointer font-mono text-[11px] uppercase tracking-[0.12em] text-signal hover:underline"
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div :if={is_nil(@selected_repository)}>
|
||||
<.input
|
||||
field={@form[:repo_query]}
|
||||
type="text"
|
||||
label="Repository"
|
||||
placeholder="Search listed repositories"
|
||||
phx-debounce="200"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<ul
|
||||
:if={@repo_results != []}
|
||||
id="bounty-repo-results"
|
||||
class="mt-2 divide-y divide-rule border-2 border-strong bg-ground"
|
||||
>
|
||||
<li :for={repository <- @repo_results}>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="pick_repository"
|
||||
phx-value-ref={repository_ref(repository)}
|
||||
class="flex w-full cursor-pointer items-baseline gap-2 px-3 py-2 text-left transition hover:bg-panel"
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate font-mono text-xs text-ink">
|
||||
{repository.owner}/{repository.name}
|
||||
</span>
|
||||
<span class="shrink-0 font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint">
|
||||
{repository.host}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<p
|
||||
:if={@repo_results == [] and not blank?(@form.params["repo_query"])}
|
||||
class="mt-2 font-mono text-[11px] text-ink-faint"
|
||||
>
|
||||
No listed repository matches.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<.input
|
||||
:if={@target_type == "infestation"}
|
||||
field={@form[:target_ref]}
|
||||
type="select"
|
||||
label="Infestation"
|
||||
options={@infestation_options}
|
||||
prompt="Choose an infestation"
|
||||
/>
|
||||
|
||||
<div :if={@target_type == "finding"}>
|
||||
<.input
|
||||
field={@form[:target_ref]}
|
||||
type="text"
|
||||
label="Finding"
|
||||
placeholder="Public id from the finding page"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<p class="mt-2 font-mono text-[11px] leading-5 text-ink-faint">
|
||||
The id in the finding's URL, <code>/findings/<id></code>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<input
|
||||
:if={@target_type == "repository"}
|
||||
type="hidden"
|
||||
name="bounty[target_ref]"
|
||||
value={@form.params["target_ref"] || ""}
|
||||
/>
|
||||
|
||||
<.input field={@form[:title]} type="text" label="Title" required maxlength="200" />
|
||||
<.input
|
||||
field={@form[:description]}
|
||||
type="textarea"
|
||||
label="What should the hunter do?"
|
||||
rows="5"
|
||||
required
|
||||
/>
|
||||
<.input
|
||||
field={@form[:funding]}
|
||||
type="select"
|
||||
label="Fund with"
|
||||
options={@fundings}
|
||||
/>
|
||||
<.input
|
||||
field={@form[:amount]}
|
||||
type="text"
|
||||
label={if @funding == "fiat", do: "Amount (USD)", else: "Amount (credits)"}
|
||||
placeholder={if @funding == "fiat", do: "250", else: "500"}
|
||||
required
|
||||
/>
|
||||
<p
|
||||
:if={@funding == "credits"}
|
||||
id="bounty-credit-balance"
|
||||
class="-mt-3 font-mono text-[11px] leading-5 text-ink-faint"
|
||||
>
|
||||
Your balance: <span class="text-ink">{@credit_balance} credits</span>
|
||||
· platform take rate applies at settlement.
|
||||
</p>
|
||||
<p
|
||||
:if={@funding == "fiat"}
|
||||
class="-mt-3 font-mono text-[11px] leading-5 text-ink-faint"
|
||||
>
|
||||
Held by Stripe until the work is accepted · platform take rate applies at settlement.
|
||||
</p>
|
||||
|
||||
<button
|
||||
id="bounty-create-button"
|
||||
type="submit"
|
||||
class="cursor-pointer border-2 border-signal px-5 py-2 font-display text-xs uppercase tracking-[0.12em] text-signal transition hover:bg-signal hover:text-ground"
|
||||
>
|
||||
Post contract
|
||||
</button>
|
||||
</.form>
|
||||
</Layouts.page>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
end
|
||||
321
lib/tarakan_web/live/bounty_live/show.ex
Normal file
321
lib/tarakan_web/live/bounty_live/show.ex
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
defmodule TarakanWeb.BountyLive.Show do
|
||||
@moduledoc "One contract: target, reward, claims, and sponsor/moderator controls."
|
||||
use TarakanWeb, :live_view
|
||||
|
||||
import TarakanWeb.BountyComponents
|
||||
|
||||
alias Tarakan.Market
|
||||
alias Tarakan.Market.Bounty
|
||||
alias Tarakan.Policy
|
||||
|
||||
@impl true
|
||||
def mount(%{"public_id" => public_id}, _session, socket) do
|
||||
bounty = Market.get_bounty!(public_id)
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Contract: #{bounty.title}")
|
||||
|> assign(:meta_description, meta_description(bounty))
|
||||
|> assign(:canonical_path, ~p"/bounties/#{bounty.public_id}")
|
||||
|> assign_bounty(bounty)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("claim", _params, socket) do
|
||||
case Market.claim_bounty(socket.assigns.current_scope, socket.assigns.bounty) do
|
||||
{:ok, _claim} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Contract claimed - the job is yours for the next two hours.")
|
||||
|> reload_bounty()}
|
||||
|
||||
{:error, reason} ->
|
||||
{:noreply, put_flash(socket, :error, claim_error_message(reason))}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("cancel", _params, socket) do
|
||||
case Market.cancel_bounty(socket.assigns.current_scope, socket.assigns.bounty) do
|
||||
{:ok, _bounty} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Contract cancelled. Escrowed funds were returned.")
|
||||
|> reload_bounty()}
|
||||
|
||||
{:error, reason} ->
|
||||
{:noreply, put_flash(socket, :error, cancel_error_message(reason))}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("settle", _params, socket) do
|
||||
case Market.settle_bounty(socket.assigns.current_scope, socket.assigns.bounty) do
|
||||
{:ok, bounty} ->
|
||||
message =
|
||||
if bounty.funding == "fiat",
|
||||
do: "Contract settled. Payout is pending manual processing.",
|
||||
else: "Contract settled. Credits were paid to the winner."
|
||||
|
||||
{:noreply, socket |> put_flash(:info, message) |> reload_bounty()}
|
||||
|
||||
{:error, reason} ->
|
||||
{:noreply, put_flash(socket, :error, settle_error_message(reason))}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("mark_paid", _params, socket) do
|
||||
case Market.mark_paid(socket.assigns.current_scope, socket.assigns.bounty) do
|
||||
{:ok, _bounty} ->
|
||||
{:noreply, socket |> put_flash(:info, "Contract marked paid.") |> reload_bounty()}
|
||||
|
||||
{:error, _reason} ->
|
||||
{:noreply, put_flash(socket, :error, "The contract could not be marked paid.")}
|
||||
end
|
||||
end
|
||||
|
||||
defp reload_bounty(socket) do
|
||||
assign_bounty(socket, Market.get_bounty!(socket.assigns.bounty.public_id))
|
||||
end
|
||||
|
||||
defp assign_bounty(socket, bounty) do
|
||||
scope = socket.assigns.current_scope
|
||||
account_id = scope && scope.account_id
|
||||
|
||||
claims = Enum.sort_by(bounty.claims, & &1.id, :desc)
|
||||
|
||||
my_claim =
|
||||
if account_id, do: Enum.find(claims, &(&1.account_id == account_id)), else: nil
|
||||
|
||||
socket
|
||||
|> assign(:bounty, bounty)
|
||||
|> assign(:claims, claims)
|
||||
|> assign(:claim_count, length(claims))
|
||||
|> assign(:my_claim, my_claim)
|
||||
|> assign(:sponsor?, account_id != nil and account_id == bounty.sponsor_account_id)
|
||||
|> assign(:winner?, account_id != nil and account_id == bounty.winner_account_id)
|
||||
|> assign(:moderator?, scope != nil and Policy.moderator?(scope))
|
||||
|> assign(:admin?, scope != nil and Policy.admin?(scope))
|
||||
|> assign(:can_claim?, can_claim?(scope, bounty))
|
||||
|> assign(:can_cancel?, can_cancel?(scope, bounty))
|
||||
end
|
||||
|
||||
defp can_claim?(nil, _bounty), do: false
|
||||
|
||||
defp can_claim?(scope, %Bounty{} = bounty) do
|
||||
scope.account_id != nil and scope.account_state == "active" and
|
||||
bounty.status == "open" and scope.account_id != bounty.sponsor_account_id
|
||||
end
|
||||
|
||||
defp can_cancel?(nil, _bounty), do: false
|
||||
|
||||
defp can_cancel?(scope, %Bounty{} = bounty) do
|
||||
bounty.status in ["pending_funding", "open"] and bounty.claims == [] and
|
||||
(scope.account_id == bounty.sponsor_account_id or Policy.moderator?(scope))
|
||||
end
|
||||
|
||||
defp meta_description(bounty) do
|
||||
"#{amount_label(bounty)} contract on #{target_label(bounty)}: #{bounty.title}"
|
||||
|> String.replace(~r/\s+/, " ")
|
||||
|> String.slice(0, 160)
|
||||
end
|
||||
|
||||
defp claim_error_message(:own_bounty), do: "You cannot claim your own contract."
|
||||
defp claim_error_message(:not_open), do: "This contract is no longer open."
|
||||
defp claim_error_message(:claim_limit), do: "You already hold too many active claims."
|
||||
defp claim_error_message(:unauthorized), do: "Your account cannot claim contracts."
|
||||
defp claim_error_message(_reason), do: "The contract could not be claimed. Try again shortly."
|
||||
|
||||
defp cancel_error_message(:has_claims), do: "A contract with claims cannot be cancelled."
|
||||
defp cancel_error_message(:invalid_state), do: "This contract can no longer be cancelled."
|
||||
defp cancel_error_message(:unauthorized), do: "You cannot cancel this contract."
|
||||
|
||||
defp cancel_error_message(_reason),
|
||||
do: "The contract could not be cancelled. Try again shortly."
|
||||
|
||||
defp settle_error_message(:no_winning_claim), do: "No submitted or accepted claim to settle."
|
||||
defp settle_error_message(:invalid_state), do: "This contract is not awaiting settlement."
|
||||
defp settle_error_message(_reason), do: "The contract could not be settled. Try again shortly."
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
|
||||
<Layouts.page width={:focused}>
|
||||
<div id={"bounty-poster-#{@bounty.public_id}"} class="border-2 border-strong">
|
||||
<div class="flex items-center justify-between border-b-2 border-strong bg-panel px-5 py-3">
|
||||
<.notch_badge class="bg-signal text-ground">Contract</.notch_badge>
|
||||
<span
|
||||
id="bounty-status"
|
||||
class="font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint"
|
||||
>
|
||||
{status_label(@bounty.status)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="px-5 py-6">
|
||||
<p id="bounty-amount" class="font-display text-5xl font-medium leading-none text-signal">
|
||||
{amount_label(@bounty)}
|
||||
</p>
|
||||
<h1 class="mt-4 font-display text-2xl font-medium uppercase leading-tight tracking-[0.02em] text-ink">
|
||||
{@bounty.title}
|
||||
</h1>
|
||||
|
||||
<dl class="mt-5 space-y-2 border-y-2 border-rule py-4 font-mono text-xs text-ink-muted">
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt class="uppercase tracking-[0.12em] text-ink-faint">Target</dt>
|
||||
<dd class="text-right">
|
||||
<%= if path = target_path(@bounty) do %>
|
||||
<.link navigate={path} class="text-signal hover:underline">
|
||||
{target_label(@bounty)}
|
||||
</.link>
|
||||
<% else %>
|
||||
{target_label(@bounty)}
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt class="uppercase tracking-[0.12em] text-ink-faint">Sponsor</dt>
|
||||
<dd>
|
||||
<.link
|
||||
navigate={~p"/#{@bounty.sponsor_account.handle}"}
|
||||
class="text-signal hover:underline"
|
||||
>
|
||||
@{@bounty.sponsor_account.handle}
|
||||
</.link>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt class="uppercase tracking-[0.12em] text-ink-faint">Funding</dt>
|
||||
<dd>{if @bounty.funding == "fiat", do: "card escrow", else: "credits"}</dd>
|
||||
</div>
|
||||
<div :if={@bounty.expires_at} class="flex justify-between gap-4">
|
||||
<dt class="uppercase tracking-[0.12em] text-ink-faint">Expires</dt>
|
||||
<dd>{Calendar.strftime(@bounty.expires_at, "%Y-%m-%d")}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt class="uppercase tracking-[0.12em] text-ink-faint">Claims</dt>
|
||||
<dd>{@claim_count}</dd>
|
||||
</div>
|
||||
<div :if={@bounty.winner_account} class="flex justify-between gap-4">
|
||||
<dt class="uppercase tracking-[0.12em] text-ink-faint">Winner</dt>
|
||||
<dd>
|
||||
<.link
|
||||
navigate={~p"/#{@bounty.winner_account.handle}"}
|
||||
class="text-signal hover:underline"
|
||||
>
|
||||
@{@bounty.winner_account.handle}
|
||||
</.link>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<p class="mt-5 whitespace-pre-wrap text-sm leading-6 text-ink">{@bounty.description}</p>
|
||||
|
||||
<div
|
||||
:if={@winner? and @bounty.status == "payout_pending"}
|
||||
class="mt-5 border-2 border-rule bg-panel px-4 py-3 text-xs leading-5 text-ink-muted"
|
||||
>
|
||||
Payout pending - the platform processes fiat payouts manually. You'll be
|
||||
contacted at your account email.
|
||||
</div>
|
||||
<div
|
||||
:if={@sponsor? and @bounty.status == "payout_pending"}
|
||||
class="mt-5 border-2 border-rule bg-panel px-4 py-3 text-xs leading-5 text-ink-muted"
|
||||
>
|
||||
Payout pending - escrowed funds are being released to the winner.
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
:if={@can_claim?}
|
||||
id="bounty-claim-button"
|
||||
type="button"
|
||||
phx-click="claim"
|
||||
class="cursor-pointer border-2 border-signal px-5 py-2 font-display text-xs uppercase tracking-[0.12em] text-signal transition hover:bg-signal hover:text-ground"
|
||||
>
|
||||
Claim this contract
|
||||
</button>
|
||||
<.link
|
||||
:if={not @can_claim? and @bounty.status == "open" and @current_scope == nil}
|
||||
id="bounty-login-to-claim"
|
||||
navigate={~p"/accounts/log-in"}
|
||||
class="border-2 border-signal px-5 py-2 font-display text-xs uppercase tracking-[0.12em] text-signal transition hover:bg-signal hover:text-ground"
|
||||
>
|
||||
Log in to claim
|
||||
</.link>
|
||||
<button
|
||||
:if={@can_cancel?}
|
||||
id="bounty-cancel-button"
|
||||
type="button"
|
||||
phx-click="cancel"
|
||||
data-confirm="Cancel this contract? Escrowed funds will be returned."
|
||||
class="cursor-pointer border border-strong px-5 py-2 font-display text-xs uppercase tracking-[0.12em] text-ink-muted transition hover:border-ink hover:text-ink"
|
||||
>
|
||||
Cancel contract
|
||||
</button>
|
||||
<button
|
||||
:if={@moderator? and @bounty.status == "claimed"}
|
||||
id="bounty-settle-button"
|
||||
type="button"
|
||||
phx-click="settle"
|
||||
class="cursor-pointer border-2 border-signal px-5 py-2 font-display text-xs uppercase tracking-[0.12em] text-signal transition hover:bg-signal hover:text-ground"
|
||||
>
|
||||
Settle contract
|
||||
</button>
|
||||
<button
|
||||
:if={@admin? and @bounty.status == "payout_pending"}
|
||||
id="bounty-mark-paid-button"
|
||||
type="button"
|
||||
phx-click="mark_paid"
|
||||
class="cursor-pointer border-2 border-signal px-5 py-2 font-display text-xs uppercase tracking-[0.12em] text-signal transition hover:bg-signal hover:text-ground"
|
||||
>
|
||||
Mark paid
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section :if={@claims != []} id="bounty-claims" class="mt-8">
|
||||
<h2 class="font-mono text-[11px] uppercase tracking-[0.12em] text-ink-faint">
|
||||
Claims
|
||||
</h2>
|
||||
<ul class="mt-3 divide-y divide-rule border-2 border-strong">
|
||||
<li
|
||||
:for={claim <- @claims}
|
||||
id={"bounty-claim-#{claim.id}"}
|
||||
class="flex items-center justify-between gap-4 px-4 py-3"
|
||||
>
|
||||
<span class="font-mono text-xs text-ink">
|
||||
@{claim.account.handle}
|
||||
<span :if={claim.review_task} class="text-ink-faint">
|
||||
·
|
||||
<.link
|
||||
navigate={~p"/jobs/#{claim.review_task.id}"}
|
||||
class="text-signal hover:underline"
|
||||
>job</.link>
|
||||
</span>
|
||||
</span>
|
||||
<span class="font-mono text-[10px] uppercase tracking-[0.12em] text-ink-muted">
|
||||
{claim.status}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<p
|
||||
:if={@my_claim && @my_claim.status in ["claimed", "submitted"]}
|
||||
class="mt-6 text-xs leading-5 text-ink-muted"
|
||||
>
|
||||
Your claim is backed by
|
||||
<.link
|
||||
navigate={~p"/jobs/#{@my_claim.review_task.id}"}
|
||||
class="font-semibold text-signal hover:underline"
|
||||
>
|
||||
this job
|
||||
</.link>
|
||||
- submit your work there. Settlement follows acceptance.
|
||||
</p>
|
||||
</Layouts.page>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
end
|
||||
189
lib/tarakan_web/live/client_authorization_live.ex
Normal file
189
lib/tarakan_web/live/client_authorization_live.ex
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
defmodule TarakanWeb.ClientAuthorizationLive do
|
||||
use TarakanWeb, :live_view
|
||||
|
||||
alias Tarakan.Accounts.ClientAuthorizations
|
||||
|
||||
@impl true
|
||||
def mount(%{"user_code" => user_code}, _session, socket) do
|
||||
actor_key = socket.assigns.current_scope.account.id
|
||||
|
||||
case ClientAuthorizations.get_for_browser(user_code, actor_key) do
|
||||
{:ok, authorization} ->
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:authorization, authorization)
|
||||
|> assign(
|
||||
:display_code,
|
||||
ClientAuthorizations.display_user_code(authorization.user_code)
|
||||
)
|
||||
|> assign(:page_title, "Authorize Tarakan Client")}
|
||||
|
||||
# Throttled and missing look the same on purpose: telling a grinder which
|
||||
# of its guesses were merely rate limited would hand back the oracle.
|
||||
{:error, _reason} ->
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:authorization, nil)
|
||||
|> assign(:display_code, user_code)
|
||||
|> assign(:page_title, "Login request expired")}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event(action, _params, %{assigns: %{authorization: nil}} = socket)
|
||||
when action in ["approve", "deny"] do
|
||||
{:noreply, put_flash(socket, :error, "This login request has expired or was already used.")}
|
||||
end
|
||||
|
||||
def handle_event("approve", _params, socket) do
|
||||
account = socket.assigns.current_scope.account
|
||||
|
||||
case ClientAuthorizations.approve(socket.assigns.authorization, account) do
|
||||
{:ok, authorization} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:authorization, authorization)
|
||||
|> put_flash(:info, "Connected. You can go back to the terminal.")}
|
||||
|
||||
{:error, _reason} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:authorization, nil)
|
||||
|> put_flash(:error, "This login request expired or was already used.")}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("deny", _params, socket) do
|
||||
account = socket.assigns.current_scope.account
|
||||
|
||||
case ClientAuthorizations.deny(socket.assigns.authorization, account) do
|
||||
{:ok, authorization} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:authorization, authorization)
|
||||
|> put_flash(:info, "Denied.")}
|
||||
|
||||
{:error, _reason} ->
|
||||
{:noreply, assign(socket, :authorization, nil)}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
|
||||
<Layouts.page id="client-authorization-page" width={:compact}>
|
||||
<section class={[
|
||||
"border-2 border-strong bg-panel p-6 shadow-[8px_8px_0_0_var(--color-rule)] sm:p-8"
|
||||
]}>
|
||||
<div class={["flex items-start gap-4"]}>
|
||||
<div class={[
|
||||
"flex size-11 shrink-0 items-center justify-center bg-signal text-ground"
|
||||
]}>
|
||||
<.icon name="hero-command-line" class={["size-6"]} />
|
||||
</div>
|
||||
<div class={["min-w-0"]}>
|
||||
<h1 class={[
|
||||
"font-display text-2xl font-medium uppercase tracking-[0.02em] text-ink"
|
||||
]}>
|
||||
Connect your terminal
|
||||
</h1>
|
||||
<p class={["mt-2 text-sm leading-6 text-ink-muted"]}>
|
||||
Approving lets the client claim jobs as @{@current_scope.account.handle}.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= cond do %>
|
||||
<% is_nil(@authorization) -> %>
|
||||
<div id="client-authorization-expired" class={["mt-8 border-2 border-rule p-5"]}>
|
||||
<p class={["text-sm text-ink"]}>
|
||||
That code is invalid or expired. Run <code class="font-mono">tarakan login</code>
|
||||
again.
|
||||
</p>
|
||||
</div>
|
||||
<% @authorization.status == "approved" -> %>
|
||||
<div
|
||||
id="client-authorization-approved"
|
||||
class={["mt-8 border-2 border-phosphor bg-ground p-5"]}
|
||||
>
|
||||
<p class={["text-sm font-semibold text-ink"]}>Connected.</p>
|
||||
<p class={["mt-1 text-sm text-ink-muted"]}>Back to your terminal.</p>
|
||||
</div>
|
||||
<% @authorization.status == "denied" -> %>
|
||||
<div id="client-authorization-denied" class={["mt-8 border-2 border-rule p-5"]}>
|
||||
<p class={["text-sm text-ink"]}>Denied. Nothing was granted.</p>
|
||||
</div>
|
||||
<% true -> %>
|
||||
<div id="client-authorization-pending" class={["mt-8"]}>
|
||||
<p class={["text-xs font-semibold text-ink-muted"]}>
|
||||
Code
|
||||
</p>
|
||||
<%!-- Deliberately outside the two-token tracking scale (0.12em
|
||||
labels / 0.02em display): this is a code the user transcribes
|
||||
by hand, and the wide gaps keep characters from running together. --%>
|
||||
<div
|
||||
id="client-authorization-code"
|
||||
class={[
|
||||
"mt-2 border-2 border-strong bg-ground px-4 py-3 font-mono text-2xl tracking-[0.2em] text-ink sm:text-3xl"
|
||||
]}
|
||||
>
|
||||
{@display_code}
|
||||
</div>
|
||||
|
||||
<%!-- The name comes from whoever started the login, not from
|
||||
Tarakan, so it is attributed rather than asserted: a
|
||||
request that calls itself something official must not read
|
||||
as though this page vouches for it. --%>
|
||||
<p class={["mt-5 text-xs font-semibold text-ink-muted"]}>
|
||||
Calls itself
|
||||
</p>
|
||||
<p
|
||||
id="client-authorization-client-name"
|
||||
class={[
|
||||
"mt-1 border-2 border-rule bg-ground px-3 py-2 font-mono text-sm text-ink"
|
||||
]}
|
||||
>
|
||||
{@authorization.client_name}
|
||||
</p>
|
||||
|
||||
<p class={["mt-4 text-sm text-ink-muted"]}>
|
||||
Approving lets it claim jobs and submit reviews as
|
||||
<span class="font-semibold text-ink">@{@current_scope.account.handle}</span>
|
||||
for about 7 days. Revoke anytime in settings.
|
||||
</p>
|
||||
<p class={["mt-2 text-sm text-ink-muted"]}>
|
||||
Only approve this if you just ran
|
||||
<code class="font-mono text-ink">tarakan login</code>
|
||||
yourself and the code above matches your terminal. Nobody at Tarakan will
|
||||
ever ask you for it.
|
||||
</p>
|
||||
|
||||
<div class={["mt-6 grid gap-3 sm:grid-cols-2"]}>
|
||||
<button
|
||||
id="client-authorization-deny-button"
|
||||
phx-click="deny"
|
||||
class={[
|
||||
"flex h-12 items-center justify-center border border-strong px-4 font-display text-sm uppercase tracking-[0.12em] text-ink transition hover:bg-ground"
|
||||
]}
|
||||
>
|
||||
Deny
|
||||
</button>
|
||||
<button
|
||||
id="client-authorization-approve-button"
|
||||
phx-click="approve"
|
||||
class={[
|
||||
"clip-notch flex h-12 items-center justify-center bg-btn px-4 font-display text-sm uppercase tracking-[0.12em] text-btn-fg transition hover:opacity-90"
|
||||
]}
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</section>
|
||||
</Layouts.page>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
end
|
||||
139
lib/tarakan_web/live/explore_live.ex
Normal file
139
lib/tarakan_web/live/explore_live.ex
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
defmodule TarakanWeb.ExploreLive do
|
||||
use TarakanWeb, :live_view
|
||||
|
||||
alias Tarakan.Activity
|
||||
alias Tarakan.Infestations
|
||||
alias Tarakan.Reputation
|
||||
alias TarakanWeb.Presence
|
||||
|
||||
@wire_limit 50
|
||||
# A search sweeps a wider slice of history than the visible window.
|
||||
@search_scan_limit 200
|
||||
@presence_topic "explore:observers"
|
||||
|
||||
# Product language: Reports / Checks. Wire kinds stay :scan / :verdict internally.
|
||||
@kinds %{
|
||||
"all" => nil,
|
||||
"registrations" => :registration,
|
||||
"reports" => :scan,
|
||||
"checks" => :verdict,
|
||||
"comments" => :comment
|
||||
}
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
if connected?(socket) do
|
||||
Activity.subscribe()
|
||||
Reputation.subscribe()
|
||||
Phoenix.PubSub.subscribe(Tarakan.PubSub, @presence_topic)
|
||||
{:ok, _ref} = Presence.track(self(), @presence_topic, socket.id, %{})
|
||||
end
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Explore")
|
||||
|> assign(
|
||||
:meta_description,
|
||||
"Public record: Reports, Checks, registrations, discussion."
|
||||
)
|
||||
|> assign(:canonical_path, ~p"/explore")
|
||||
|> assign(:kind, "all")
|
||||
|> assign(:query, "")
|
||||
|> assign(:watcher_count, watcher_count())
|
||||
|> assign(:hot_findings, Activity.hot_findings())
|
||||
|> assign(:infestations, Infestations.list_infestations(min_repos: 2, days: 30, limit: 5))
|
||||
|> load_wire()}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("filter", %{"kind" => kind}, socket) when is_map_key(@kinds, kind) do
|
||||
{:noreply, socket |> assign(:kind, kind) |> load_wire()}
|
||||
end
|
||||
|
||||
def handle_event("search", params, socket) do
|
||||
query = params |> Map.get("q", "") |> String.trim()
|
||||
{:noreply, socket |> assign(:query, query) |> load_wire()}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:activity, entry}, socket) do
|
||||
if matches?(entry, socket.assigns.kind, socket.assigns.query) do
|
||||
{:noreply, stream_insert(socket, :wire, entry, at: 0, limit: @wire_limit)}
|
||||
else
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_info({:vote_changed, "canonical_finding", _id}, socket) do
|
||||
{:noreply, assign(socket, :hot_findings, Activity.hot_findings())}
|
||||
end
|
||||
|
||||
def handle_info({:vote_changed, _subject_type, _id}, socket), do: {:noreply, socket}
|
||||
|
||||
def handle_info(%Phoenix.Socket.Broadcast{event: "presence_diff"}, socket) do
|
||||
{:noreply, assign(socket, :watcher_count, watcher_count())}
|
||||
end
|
||||
|
||||
defp load_wire(socket) do
|
||||
%{kind: kind, query: query} = socket.assigns
|
||||
scan_limit = if query == "", do: @wire_limit, else: @search_scan_limit
|
||||
|
||||
# Scope the kind in the query so a rare kind returns its own `scan_limit`
|
||||
# rows instead of being crowded out of the merged newest-N window.
|
||||
entries =
|
||||
scan_limit
|
||||
|> Activity.recent(verified_only: false, kind: Map.fetch!(@kinds, kind))
|
||||
|> Enum.filter(&matches_query?(&1, query))
|
||||
|> Enum.take(@wire_limit)
|
||||
|
||||
stream(socket, :wire, entries, reset: true)
|
||||
end
|
||||
|
||||
defp matches?(entry, kind, query) do
|
||||
matches_kind?(entry, kind) and matches_query?(entry, query)
|
||||
end
|
||||
|
||||
defp matches_kind?(_entry, "all"), do: true
|
||||
defp matches_kind?(entry, kind), do: entry.kind == Map.fetch!(@kinds, kind)
|
||||
|
||||
defp matches_query?(_entry, ""), do: true
|
||||
|
||||
defp matches_query?(entry, query) do
|
||||
haystack =
|
||||
[
|
||||
Map.get(entry, :handle),
|
||||
Map.get(entry, :finding_title),
|
||||
entry.host,
|
||||
"#{entry.owner}/#{entry.name}"
|
||||
]
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|> Enum.join(" ")
|
||||
|> String.downcase()
|
||||
|
||||
String.contains?(haystack, String.downcase(query))
|
||||
end
|
||||
|
||||
defp watcher_count do
|
||||
@presence_topic |> Presence.list() |> map_size()
|
||||
end
|
||||
|
||||
# Reviews and verdicts live on the security tab; comments on the finding
|
||||
# page; a registration points at the repository itself.
|
||||
defp wire_entry_path(%{kind: :registration} = entry) do
|
||||
TarakanWeb.RepositoryPaths.repository_path(entry)
|
||||
end
|
||||
|
||||
defp wire_entry_path(%{kind: :comment} = entry) do
|
||||
"/findings/#{entry.finding_public_id}"
|
||||
end
|
||||
|
||||
defp wire_entry_path(entry) do
|
||||
TarakanWeb.RepositoryPaths.repository_security_path(entry)
|
||||
end
|
||||
|
||||
defp ledger_time(%DateTime{} = datetime) do
|
||||
Calendar.strftime(datetime, "%Y-%m-%d %H:%M")
|
||||
end
|
||||
|
||||
defp filter_active?(current, value), do: current == value
|
||||
end
|
||||
263
lib/tarakan_web/live/explore_live.html.heex
Normal file
263
lib/tarakan_web/live/explore_live.html.heex
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
|
||||
<Layouts.page>
|
||||
<.breadcrumbs id="explore-breadcrumb">
|
||||
<:crumb navigate={~p"/"}>registry</:crumb>
|
||||
<:crumb>explore</:crumb>
|
||||
</.breadcrumbs>
|
||||
|
||||
<div class="flex flex-col gap-3 border-b-2 border-strong pb-5 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h1 class="font-display text-3xl font-medium uppercase tracking-[0.02em] text-ink sm:text-4xl">
|
||||
Explore
|
||||
</h1>
|
||||
<p class="mt-2 max-w-2xl text-sm leading-6 text-ink-muted">
|
||||
Reports, Checks, registrations, and discussion as they hit the record.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-start gap-2 sm:items-end">
|
||||
<span
|
||||
id="explore-watchers"
|
||||
class="flex items-center gap-2 font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint"
|
||||
>
|
||||
<span class="size-1.5 rounded-full bg-phosphor"></span>
|
||||
<span><span class="text-phosphor">{@watcher_count}</span> watching</span>
|
||||
</span>
|
||||
<div
|
||||
id="explore-filter"
|
||||
class="flex max-w-full overflow-x-auto overscroll-x-contain border border-strong font-display text-[10px] uppercase tracking-[0.12em] [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
role="group"
|
||||
aria-label="Filter by kind"
|
||||
>
|
||||
<button
|
||||
:for={
|
||||
{value, label} <- [
|
||||
{"all", "Everything"},
|
||||
{"registrations", "Registrations"},
|
||||
{"reports", "Reports"},
|
||||
{"checks", "Checks"},
|
||||
{"comments", "Comments"}
|
||||
]
|
||||
}
|
||||
type="button"
|
||||
phx-click="filter"
|
||||
phx-value-kind={value}
|
||||
aria-pressed={to_string(filter_active?(@kind, value))}
|
||||
class={[
|
||||
"shrink-0 px-3 py-2 transition sm:py-1.5",
|
||||
filter_active?(@kind, value) && "bg-btn text-btn-fg",
|
||||
!filter_active?(@kind, value) && "text-ink-faint hover:text-ink"
|
||||
]}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid gap-10 lg:grid-cols-[minmax(0,1fr)_minmax(15rem,0.32fr)]">
|
||||
<section class="min-w-0">
|
||||
<form id="explore-search-form" phx-change="search" phx-submit="search">
|
||||
<label
|
||||
for="explore-search-input"
|
||||
class="group flex cursor-text items-center gap-3 border-b border-rule px-3 sm:px-4"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="select-none font-mono text-sm text-ink-faint transition group-focus-within:text-phosphor"
|
||||
>
|
||||
>
|
||||
</span>
|
||||
<input
|
||||
id="explore-search-input"
|
||||
type="text"
|
||||
name="q"
|
||||
value={@query}
|
||||
autocomplete="off"
|
||||
placeholder="search the wire: handle or repository"
|
||||
phx-debounce="200"
|
||||
phx-hook="SearchShortcut"
|
||||
class="h-12 w-full border-0 bg-transparent font-mono text-xs text-ink outline-none placeholder:text-ink-faint focus:ring-0"
|
||||
/>
|
||||
<kbd class="hidden shrink-0 border border-rule px-1.5 py-0.5 font-mono text-[10px] text-ink-faint sm:block">
|
||||
/
|
||||
</kbd>
|
||||
</label>
|
||||
</form>
|
||||
|
||||
<div id="activity-wire" phx-update="stream" class="divide-y divide-rule font-mono text-xs">
|
||||
<div
|
||||
id="activity-wire-empty"
|
||||
class="hidden px-3 py-16 text-center text-ink-faint only:block sm:px-4"
|
||||
>
|
||||
Nothing on the record matches this view yet.
|
||||
</div>
|
||||
<article
|
||||
:for={{id, entry} <- @streams.wire}
|
||||
id={id}
|
||||
class="flex items-baseline gap-x-3 px-3 py-3 transition-colors hover:bg-panel sm:px-4"
|
||||
>
|
||||
<span class="shrink-0 text-[10px] tabular-nums text-ink-faint">
|
||||
{ledger_time(entry.at)}
|
||||
</span>
|
||||
<span :if={entry.kind == :registration} class="min-w-0 truncate text-ink-muted">
|
||||
<span class="text-ink-faint">registered</span>
|
||||
<.link
|
||||
navigate={wire_entry_path(entry)}
|
||||
class="transition hover:text-ink hover:underline"
|
||||
>{entry.host}/{entry.owner}/{entry.name}</.link>
|
||||
</span>
|
||||
<span :if={entry.kind == :scan} class="min-w-0 truncate text-ink-muted">
|
||||
<.handle_link handle={entry.handle} class="font-semibold" />
|
||||
<span class="text-ink-faint">reported on</span>
|
||||
<.link
|
||||
navigate={wire_entry_path(entry)}
|
||||
class="transition hover:text-ink hover:underline"
|
||||
>{entry.owner}/{entry.name}</.link>
|
||||
<span class="text-ink-faint">
|
||||
{String.downcase(provenance_label(entry.provenance))} · {String.slice(
|
||||
entry.commit_sha,
|
||||
0,
|
||||
7
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<span :if={entry.kind == :verdict} class="min-w-0 truncate text-ink-muted">
|
||||
<.handle_link handle={entry.handle} class="font-semibold" />
|
||||
<span class="text-ink-faint">{entry.verdict} a finding on</span>
|
||||
<.link
|
||||
navigate={wire_entry_path(entry)}
|
||||
class="transition hover:text-ink hover:underline"
|
||||
>{entry.owner}/{entry.name}</.link>
|
||||
</span>
|
||||
<span :if={entry.kind == :comment} class="min-w-0 truncate text-ink-muted">
|
||||
<.handle_link handle={entry.handle} class="font-semibold" />
|
||||
<span class="text-ink-faint">commented on</span>
|
||||
<.link
|
||||
navigate={wire_entry_path(entry)}
|
||||
class="transition hover:text-ink hover:underline"
|
||||
>{entry.finding_title || "a finding"}</.link>
|
||||
<span class="text-ink-faint">in {entry.owner}/{entry.name}</span>
|
||||
</span>
|
||||
<span class="ml-auto shrink-0">
|
||||
<span :if={entry.kind == :registration} class="text-[10px] text-ink-faint">
|
||||
<span :if={entry.language}>{entry.language} · </span>★ {compact_stars(
|
||||
entry.stars_count
|
||||
)}
|
||||
</span>
|
||||
<.link :if={entry.kind == :scan} navigate={wire_entry_path(entry)}>
|
||||
<.notch_badge
|
||||
:if={entry.findings_count == 0 and entry.review_kind == "code_review"}
|
||||
class="text-ink-muted"
|
||||
>
|
||||
No findings reported
|
||||
</.notch_badge>
|
||||
<.notch_badge
|
||||
:if={entry.findings_count == 0 and entry.review_kind != "code_review"}
|
||||
class="text-ink-muted"
|
||||
>
|
||||
Reported
|
||||
</.notch_badge>
|
||||
<.notch_badge :if={entry.findings_count > 0} class="text-signal">
|
||||
{entry.findings_count}
|
||||
{if entry.findings_count == 1, do: "finding", else: "findings"}
|
||||
</.notch_badge>
|
||||
</.link>
|
||||
<.link :if={entry.kind == :verdict} navigate={wire_entry_path(entry)}>
|
||||
<.notch_badge
|
||||
:if={entry.verdict == "confirmed" and entry.scan_verified}
|
||||
class="bg-ink text-ground"
|
||||
>
|
||||
Verified
|
||||
</.notch_badge>
|
||||
<.notch_badge :if={entry.verdict == "disputed"} class="text-signal">
|
||||
Disputed
|
||||
</.notch_badge>
|
||||
</.link>
|
||||
</span>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="space-y-10">
|
||||
<div id="hot-infestations">
|
||||
<div class="flex items-baseline justify-between gap-3 border-b-2 border-strong px-3 pb-3">
|
||||
<h2 class="font-display text-sm uppercase tracking-[0.12em] text-ink">
|
||||
Infestations
|
||||
</h2>
|
||||
<.link
|
||||
navigate={~p"/infestations"}
|
||||
class="font-mono text-[10px] uppercase tracking-[0.12em] text-signal hover:underline"
|
||||
>
|
||||
All →
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
<p :if={@infestations == []} class="px-3 py-6 font-mono text-xs text-ink-faint">
|
||||
No infestations this month.
|
||||
</p>
|
||||
|
||||
<ol :if={@infestations != []} class="divide-y divide-rule">
|
||||
<li :for={infestation <- @infestations}>
|
||||
<.link
|
||||
id={"hot-infestation-#{infestation.pattern_key}"}
|
||||
navigate={~p"/infestations/#{infestation.pattern_key}"}
|
||||
class="group flex items-baseline gap-4 px-3 py-4 transition-colors hover:bg-panel"
|
||||
>
|
||||
<span class="shrink-0 font-mono text-xs tabular-nums text-signal">
|
||||
{infestation.repo_count}
|
||||
</span>
|
||||
<span class="min-w-0">
|
||||
<span class="block truncate text-sm font-semibold leading-5 text-ink transition group-hover:underline">
|
||||
{infestation.title}
|
||||
</span>
|
||||
<span class="mt-1.5 block font-mono text-[11px] text-ink-faint">
|
||||
{infestation.repo_count} repos · {infestation.open_count} open
|
||||
</span>
|
||||
</span>
|
||||
</.link>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div id="hot-findings">
|
||||
<div class="flex items-baseline justify-between gap-3 border-b-2 border-strong px-3 pb-3">
|
||||
<h2 class="font-display text-sm uppercase tracking-[0.12em] text-ink">
|
||||
Hot findings
|
||||
</h2>
|
||||
<span class="font-mono text-[11px] text-ink-faint">
|
||||
Last 7 days
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p :if={@hot_findings == []} class="px-3 py-8 font-mono text-xs text-ink-faint">
|
||||
No findings upvoted this week.
|
||||
</p>
|
||||
|
||||
<ol :if={@hot_findings != []} class="divide-y divide-rule">
|
||||
<li :for={finding <- @hot_findings}>
|
||||
<.link
|
||||
id={"hot-finding-#{finding.id}"}
|
||||
navigate={~p"/findings/#{finding.public_id}"}
|
||||
class="group flex items-baseline gap-4 px-3 py-4 transition-colors hover:bg-panel"
|
||||
>
|
||||
<span class="shrink-0 font-mono text-xs tabular-nums text-phosphor">
|
||||
+{finding.score}
|
||||
</span>
|
||||
<span class="min-w-0">
|
||||
<span class="block truncate text-sm font-semibold leading-5 text-ink transition group-hover:underline">
|
||||
{finding.title}
|
||||
</span>
|
||||
<span class="mt-1.5 block truncate font-mono text-[11px] text-ink-faint">
|
||||
{finding.owner}/{finding.name}
|
||||
<span :if={finding.severity}> · {finding.severity}</span>
|
||||
</span>
|
||||
</span>
|
||||
</.link>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</Layouts.page>
|
||||
</Layouts.app>
|
||||
454
lib/tarakan_web/live/finding_live/show.ex
Normal file
454
lib/tarakan_web/live/finding_live/show.ex
Normal file
|
|
@ -0,0 +1,454 @@
|
|||
defmodule TarakanWeb.FindingLive.Show do
|
||||
use TarakanWeb, :live_view
|
||||
|
||||
alias Tarakan.Discussion
|
||||
alias Tarakan.FindingMemory
|
||||
alias Tarakan.Policy
|
||||
alias Tarakan.Reputation
|
||||
alias Tarakan.Scans
|
||||
alias Tarakan.Scans.Finding
|
||||
alias Tarakan.Work
|
||||
|
||||
@impl true
|
||||
def mount(%{"public_id" => public_id}, _session, socket) do
|
||||
{scan, finding} = fetch_finding!(socket, public_id)
|
||||
|
||||
if connected?(socket) do
|
||||
Scans.subscribe(scan.repository_id)
|
||||
Discussion.subscribe(finding.id)
|
||||
Reputation.subscribe()
|
||||
end
|
||||
|
||||
public_url = TarakanWeb.Endpoint.url() <> ~p"/findings/#{finding.public_id}"
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, finding.title)
|
||||
|> assign(:meta_description, meta_description(scan, finding))
|
||||
|> assign(:canonical_path, ~p"/findings/#{finding.public_id}")
|
||||
|> assign(:public_url, public_url)
|
||||
|> assign(
|
||||
:open_bounties,
|
||||
Tarakan.Market.open_bounties_for_target(:finding, finding.canonical_finding_id)
|
||||
)
|
||||
|> assign(:og_type, "article")
|
||||
|> assign(:first_finder, FindingMemory.first_finder(finding.canonical_finding_id))
|
||||
|> assign(:json_ld, finding_json_ld(scan, finding))
|
||||
|> assign(:comment_body, "")
|
||||
|> assign(:reply_to, nil)
|
||||
|> assign(:can_vote, can_vote?(socket))
|
||||
|> assign_record(scan, finding)
|
||||
|> load_comments()
|
||||
|> load_votes()}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event(
|
||||
"record_finding_verdict",
|
||||
%{"verdict" => verdict} = params,
|
||||
%{assigns: %{current_scope: %{account: account}}} = socket
|
||||
)
|
||||
when not is_nil(account) do
|
||||
attrs = %{
|
||||
"commit_sha" => socket.assigns.scan.commit_sha,
|
||||
"verdict" => verdict,
|
||||
"provenance" => "human",
|
||||
"notes" => params["notes"]
|
||||
}
|
||||
|
||||
case FindingMemory.record_check(
|
||||
socket.assigns.current_scope,
|
||||
socket.assigns.repository,
|
||||
socket.assigns.finding.canonical_finding.public_id,
|
||||
attrs
|
||||
) do
|
||||
{:ok, _check, _canonical} ->
|
||||
{:noreply, socket |> refresh_record() |> load_votes()}
|
||||
|
||||
{:error, :conflict_of_interest} ->
|
||||
{:noreply, put_flash(socket, :error, "You cannot verify a finding you submitted.")}
|
||||
|
||||
{:error, :unauthorized} ->
|
||||
{:noreply, put_flash(socket, :error, "You are not authorized to verify this finding.")}
|
||||
|
||||
{:error, %Ecto.Changeset{errors: [{_field, {message, _meta}} | _]}} ->
|
||||
{:noreply, put_flash(socket, :error, "Finding check not recorded: #{message}.")}
|
||||
|
||||
{:error, _reason} ->
|
||||
{:noreply, put_flash(socket, :error, "Finding check could not be recorded.")}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("record_finding_verdict", _params, socket) do
|
||||
{:noreply, redirect(socket, to: ~p"/accounts/log-in")}
|
||||
end
|
||||
|
||||
def handle_event(
|
||||
"record_vendor_notification",
|
||||
%{"vendor_notification" => %{"notified_on" => notified_on}},
|
||||
%{assigns: %{current_scope: %{account: account}}} = socket
|
||||
)
|
||||
when not is_nil(account) do
|
||||
case parse_notified_on(notified_on) do
|
||||
{:ok, notified_at} ->
|
||||
case FindingMemory.record_vendor_notification(
|
||||
socket.assigns.current_scope,
|
||||
socket.assigns.repository,
|
||||
socket.assigns.finding.canonical_finding.public_id,
|
||||
%{"vendor_notified_at" => notified_at}
|
||||
) do
|
||||
{:ok, _canonical} ->
|
||||
Scans.broadcast_refresh(socket.assigns.scan)
|
||||
{:noreply, refresh_record(socket)}
|
||||
|
||||
{:error, :unauthorized} ->
|
||||
{:noreply,
|
||||
put_flash(socket, :error, "You are not authorized to record vendor notification.")}
|
||||
|
||||
{:error, _reason} ->
|
||||
{:noreply, put_flash(socket, :error, "Vendor notification could not be recorded.")}
|
||||
end
|
||||
|
||||
:error ->
|
||||
{:noreply, put_flash(socket, :error, "Enter a valid notification date.")}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("open_finding_job", %{"kind" => kind}, socket) do
|
||||
finding = socket.assigns.finding.canonical_finding
|
||||
|
||||
case Work.open_finding_job(socket.assigns.current_scope, finding, kind) do
|
||||
{:ok, :skipped_duplicate} ->
|
||||
{:noreply, put_flash(socket, :info, "That job is already open on this finding.")}
|
||||
|
||||
{:ok, task} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Job opened.")
|
||||
|> push_navigate(to: ~p"/jobs/#{task.id}")}
|
||||
|
||||
{:error, :unauthorized} ->
|
||||
{:noreply, put_flash(socket, :error, "Not authorized to open jobs here.")}
|
||||
|
||||
{:error, _reason} ->
|
||||
{:noreply, put_flash(socket, :error, "Could not open that job.")}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("record_vendor_notification", _params, socket) do
|
||||
{:noreply, redirect(socket, to: ~p"/accounts/log-in")}
|
||||
end
|
||||
|
||||
def handle_event("reply_to", %{"parent" => parent_id}, socket) do
|
||||
{:noreply, assign(socket, :reply_to, parent_id)}
|
||||
end
|
||||
|
||||
def handle_event("cancel_reply", _params, socket) do
|
||||
{:noreply, assign(socket, :reply_to, nil)}
|
||||
end
|
||||
|
||||
def handle_event(
|
||||
"post_comment",
|
||||
%{"body" => body} = params,
|
||||
%{assigns: %{current_scope: %{account: account}}} = socket
|
||||
)
|
||||
when not is_nil(account) do
|
||||
attrs = %{"body" => body, "parent_id" => params["parent_id"]}
|
||||
|
||||
case Discussion.create_comment(socket.assigns.current_scope, socket.assigns.finding, attrs) do
|
||||
{:ok, _comment} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:comment_body, "")
|
||||
|> assign(:reply_to, nil)
|
||||
|> load_comments()}
|
||||
|
||||
{:error, :too_deep} ->
|
||||
{:noreply, put_flash(socket, :error, "This thread is too deeply nested to reply to.")}
|
||||
|
||||
{:error, reason} when reason in [:invalid_parent, :not_found] ->
|
||||
{:noreply, put_flash(socket, :error, "That comment is no longer available.")}
|
||||
|
||||
{:error, :unauthorized} ->
|
||||
{:noreply,
|
||||
put_flash(socket, :error, "Your account cannot post to the discussion right now.")}
|
||||
|
||||
{:error, %Ecto.Changeset{}} ->
|
||||
{:noreply, put_flash(socket, :error, "Write a comment before posting.")}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("post_comment", _params, socket) do
|
||||
{:noreply, redirect(socket, to: ~p"/accounts/log-in")}
|
||||
end
|
||||
|
||||
def handle_event(
|
||||
"remove_comment",
|
||||
%{"id" => id, "reason" => reason},
|
||||
%{assigns: %{current_scope: %{account: account}}} = socket
|
||||
)
|
||||
when not is_nil(account) do
|
||||
with {:ok, comment} <- Discussion.get_comment(id),
|
||||
{:ok, _removed} <-
|
||||
Discussion.remove_comment(socket.assigns.current_scope, comment, %{
|
||||
"removed_reason" => reason
|
||||
}) do
|
||||
{:noreply, load_comments(socket)}
|
||||
else
|
||||
_error ->
|
||||
{:noreply, put_flash(socket, :error, "The comment could not be removed.")}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event(
|
||||
"vote",
|
||||
%{"type" => subject_type, "id" => subject_id, "vote" => value},
|
||||
%{assigns: %{current_scope: %{account: account}}} = socket
|
||||
)
|
||||
when not is_nil(account) do
|
||||
with {:ok, subject_id} <- normalize_id(subject_id),
|
||||
{:ok, value} <- normalize_vote(value) do
|
||||
case Reputation.cast_vote(
|
||||
socket.assigns.current_scope,
|
||||
subject_type,
|
||||
subject_id,
|
||||
value
|
||||
) do
|
||||
{:ok, _summary} ->
|
||||
{:noreply, socket |> load_votes() |> load_comments()}
|
||||
|
||||
{:error, :own_content} ->
|
||||
{:noreply, put_flash(socket, :error, "You cannot vote on your own contribution.")}
|
||||
|
||||
{:error, _reason} ->
|
||||
{:noreply, put_flash(socket, :error, "Your vote could not be recorded.")}
|
||||
end
|
||||
else
|
||||
_invalid -> {:noreply, put_flash(socket, :error, "Your vote could not be recorded.")}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("vote", _params, socket) do
|
||||
{:noreply, redirect(socket, to: ~p"/accounts/log-in")}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:scan_updated, %{id: scan_id}}, %{assigns: %{scan: %{id: scan_id}}} = socket) do
|
||||
{:noreply, refresh_record(socket)}
|
||||
end
|
||||
|
||||
def handle_info({event, _comment}, socket)
|
||||
when event in [:comment_posted, :comment_removed] do
|
||||
{:noreply, socket |> load_comments() |> load_votes()}
|
||||
end
|
||||
|
||||
def handle_info({:vote_changed, _type, _id}, socket) do
|
||||
{:noreply, load_votes(socket)}
|
||||
end
|
||||
|
||||
def handle_info(_event, socket), do: {:noreply, socket}
|
||||
|
||||
defp load_comments(socket) do
|
||||
comments = Discussion.list_comments(socket.assigns.current_scope, socket.assigns.finding)
|
||||
|
||||
socket
|
||||
|> assign(:comments, comments)
|
||||
|> assign(:comment_count, Enum.count(flatten_comments(comments)))
|
||||
|> assign(
|
||||
:can_moderate_comments,
|
||||
Discussion.can_moderate?(socket.assigns.current_scope, socket.assigns.finding)
|
||||
)
|
||||
end
|
||||
|
||||
defp load_votes(socket) do
|
||||
account_id = current_account_id(socket)
|
||||
|
||||
comment_ids =
|
||||
socket.assigns |> Map.get(:comments, []) |> flatten_comments() |> Enum.map(& &1.id)
|
||||
|
||||
socket
|
||||
|> assign(
|
||||
:finding_votes,
|
||||
Reputation.vote_summary(
|
||||
"canonical_finding",
|
||||
socket.assigns.finding.canonical_finding.id,
|
||||
account_id
|
||||
)
|
||||
)
|
||||
|> assign(:comment_votes, Reputation.vote_summaries("comment", comment_ids, account_id))
|
||||
end
|
||||
|
||||
defp current_account_id(%{assigns: %{current_scope: %{account: %{id: id}}}}), do: id
|
||||
defp current_account_id(_socket), do: nil
|
||||
|
||||
# phx-value params are client-controlled; never feed them to String.to_integer/1.
|
||||
defp normalize_id(id) when is_integer(id) and id > 0, do: {:ok, id}
|
||||
|
||||
defp normalize_id(id) when is_binary(id) do
|
||||
case Integer.parse(id) do
|
||||
{parsed, ""} when parsed > 0 -> {:ok, parsed}
|
||||
_other -> {:error, :invalid_id}
|
||||
end
|
||||
end
|
||||
|
||||
defp normalize_id(_id), do: {:error, :invalid_id}
|
||||
|
||||
defp normalize_vote(value) when is_integer(value), do: {:ok, value}
|
||||
|
||||
defp normalize_vote(value) when is_binary(value) do
|
||||
case Integer.parse(value) do
|
||||
{parsed, ""} -> {:ok, parsed}
|
||||
_other -> {:error, :invalid_vote}
|
||||
end
|
||||
end
|
||||
|
||||
defp normalize_vote(_value), do: {:error, :invalid_vote}
|
||||
|
||||
defp can_vote?(socket), do: not is_nil(current_account_id(socket))
|
||||
|
||||
defp flatten_comments(comments) do
|
||||
Enum.flat_map(comments, fn comment -> [comment | flatten_comments(comment.replies)] end)
|
||||
end
|
||||
|
||||
defp refresh_record(socket) do
|
||||
{scan, finding} = fetch_finding!(socket, socket.assigns.finding.public_id)
|
||||
assign_record(socket, scan, finding)
|
||||
end
|
||||
|
||||
defp assign_record(socket, scan, finding) do
|
||||
socket
|
||||
|> assign(:scan, scan)
|
||||
|> assign(:finding, finding)
|
||||
|> assign(:repository, scan.repository)
|
||||
|> assign(
|
||||
:finding_checks,
|
||||
FindingMemory.list_checks(finding.canonical_finding.id, scan.commit_sha)
|
||||
)
|
||||
|> assign(
|
||||
:finding_trust,
|
||||
FindingMemory.trust_summary(finding.canonical_finding, scan.commit_sha)
|
||||
)
|
||||
|> assign(
|
||||
:can_check,
|
||||
FindingMemory.can_check?(
|
||||
socket.assigns.current_scope,
|
||||
scan.repository,
|
||||
finding.canonical_finding.public_id,
|
||||
scan.commit_sha
|
||||
)
|
||||
)
|
||||
|> assign(
|
||||
:can_record_vendor_notification,
|
||||
FindingMemory.can_record_vendor_notification?(
|
||||
socket.assigns.current_scope,
|
||||
scan.repository
|
||||
)
|
||||
)
|
||||
|> assign(:vendor_form, vendor_form(finding.canonical_finding))
|
||||
|> assign(:review_stake, Reputation.review_stake(scan))
|
||||
|> assign(
|
||||
:can_open_finding_job,
|
||||
Policy.allowed?(socket.assigns.current_scope, :propose_task, scan.repository)
|
||||
)
|
||||
end
|
||||
|
||||
defp fetch_finding!(socket, public_id) do
|
||||
case Scans.get_finding(socket.assigns.current_scope, public_id) do
|
||||
# Attach the resolved scan so Discussion can read the repository id
|
||||
# without another query.
|
||||
{:ok, {scan, finding}} -> {scan, %{finding | scan: scan}}
|
||||
{:error, :not_found} -> raise Ecto.NoResultsError, queryable: Finding
|
||||
end
|
||||
end
|
||||
|
||||
# Search snippets truncate around 160 characters; lead with the facts that
|
||||
# identify the finding before the free-text description.
|
||||
defp meta_description(scan, finding) do
|
||||
prefix =
|
||||
"#{String.capitalize(finding.severity)} in " <>
|
||||
"#{scan.repository.owner}/#{scan.repository.name} (#{finding.file_path}): "
|
||||
|
||||
truncate(prefix <> String.replace(finding.description, ~r/\s+/, " "), 160)
|
||||
end
|
||||
|
||||
defp finding_json_ld(scan, finding) do
|
||||
url = TarakanWeb.Endpoint.url() <> ~p"/findings/#{finding.public_id}"
|
||||
canonical = finding.canonical_finding
|
||||
|
||||
%{
|
||||
"@context" => "https://schema.org",
|
||||
"@type" => "TechArticle",
|
||||
"headline" => finding.title,
|
||||
"description" => meta_description(scan, finding),
|
||||
"url" => url,
|
||||
"datePublished" => DateTime.to_iso8601(finding.inserted_at),
|
||||
"dateModified" => DateTime.to_iso8601(finding.updated_at),
|
||||
"author" => %{
|
||||
"@type" => "Organization",
|
||||
"name" => "Tarakan public security record"
|
||||
},
|
||||
"about" => %{
|
||||
"@type" => "SoftwareSourceCode",
|
||||
"name" => "#{scan.repository.owner}/#{scan.repository.name}",
|
||||
"codeRepository" => scan.repository.canonical_url
|
||||
},
|
||||
"keywords" =>
|
||||
Enum.join(
|
||||
[
|
||||
finding.severity,
|
||||
"security finding",
|
||||
scan.repository.owner,
|
||||
scan.repository.name,
|
||||
"open source"
|
||||
],
|
||||
", "
|
||||
)
|
||||
}
|
||||
|> maybe_put_json_ld("cwe", canonical && canonical.cwe_id)
|
||||
|> maybe_put_json_ld("cve", canonical && canonical.cve_id)
|
||||
end
|
||||
|
||||
defp maybe_put_json_ld(map, _key, value) when value in [nil, false, ""], do: map
|
||||
defp maybe_put_json_ld(map, key, value), do: Map.put(map, key, value)
|
||||
|
||||
# An empty date clears the recorded vendor notification; anything else must
|
||||
# be an ISO calendar date, stored at midnight UTC.
|
||||
defp parse_notified_on(value) when value in [nil, ""], do: {:ok, nil}
|
||||
|
||||
defp parse_notified_on(value) when is_binary(value) do
|
||||
case Date.from_iso8601(value) do
|
||||
{:ok, date} -> {:ok, DateTime.new!(date, ~T[00:00:00], "Etc/UTC")}
|
||||
{:error, _reason} -> :error
|
||||
end
|
||||
end
|
||||
|
||||
defp vendor_form(canonical) do
|
||||
notified_on =
|
||||
case canonical.vendor_notified_at do
|
||||
%DateTime{} = datetime -> datetime |> DateTime.to_date() |> Date.to_iso8601()
|
||||
_other -> ""
|
||||
end
|
||||
|
||||
to_form(%{"notified_on" => notified_on}, as: :vendor_notification)
|
||||
end
|
||||
|
||||
defp truncate(text, max) when byte_size(text) <= max, do: text
|
||||
|
||||
defp truncate(text, max) do
|
||||
String.slice(text, 0, max - 1) <> "…"
|
||||
end
|
||||
|
||||
defp record_time(%DateTime{} = datetime) do
|
||||
Calendar.strftime(datetime, "%Y-%m-%d %H:%M")
|
||||
end
|
||||
|
||||
defp stake_label(:at_risk), do: "at risk (awaiting review)"
|
||||
defp stake_label(:returned), do: "returned (verified)"
|
||||
defp stake_label(:slashed), do: "slashed (refuted)"
|
||||
|
||||
defp finding_lines(%{line_start: nil}), do: ""
|
||||
defp finding_lines(%{line_start: line, line_end: line}), do: ":#{line}"
|
||||
|
||||
defp finding_lines(%{line_start: line_start, line_end: line_end}),
|
||||
do: ":#{line_start}-#{line_end}"
|
||||
end
|
||||
611
lib/tarakan_web/live/finding_live/show.html.heex
Normal file
611
lib/tarakan_web/live/finding_live/show.html.heex
Normal file
|
|
@ -0,0 +1,611 @@
|
|||
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
|
||||
<Layouts.page>
|
||||
<.breadcrumbs id="finding-breadcrumb">
|
||||
<:crumb navigate={~p"/"}>registry</:crumb>
|
||||
<:crumb navigate={TarakanWeb.RepositoryPaths.repository_security_path(@repository)}>
|
||||
{@repository.owner}/{@repository.name}
|
||||
</:crumb>
|
||||
<:crumb>finding</:crumb>
|
||||
</.breadcrumbs>
|
||||
|
||||
<TarakanWeb.BountyComponents.wanted_banner bounties={@open_bounties} />
|
||||
|
||||
<%!-- Status + trust chips --%>
|
||||
<% canonical = @finding.canonical_finding %>
|
||||
<% cwe_url = canonical && TarakanWeb.FindingPresentation.cwe_url(canonical.cwe_id) %>
|
||||
<% cve_url = canonical && TarakanWeb.FindingPresentation.cve_url(canonical.cve_id) %>
|
||||
<%!-- Chips are reserved for the two things that rank this finding against
|
||||
every other one: how bad it is, and whether anyone has confirmed it.
|
||||
Everything else - trust signals, references, run counts - is evidence,
|
||||
and evidence reads better as a line than as a wall of badges. --%>
|
||||
<div class="mt-4 flex flex-wrap items-center gap-2">
|
||||
<.notch_badge id="finding-severity" class="bg-signal text-ground">
|
||||
{@finding.severity}
|
||||
</.notch_badge>
|
||||
<.notch_badge
|
||||
:if={@finding.canonical_finding && @finding.canonical_finding.status == "verified"}
|
||||
id="finding-verified-badge"
|
||||
class="bg-ink text-ground"
|
||||
>
|
||||
Verified
|
||||
</.notch_badge>
|
||||
<.notch_badge
|
||||
:if={
|
||||
@finding.canonical_finding && @finding.canonical_finding.status not in [nil, "verified"]
|
||||
}
|
||||
class="text-ink-muted"
|
||||
>
|
||||
{@finding.canonical_finding.status}
|
||||
</.notch_badge>
|
||||
<.notch_badge
|
||||
:if={canonical && canonical.reproductions_count > 0}
|
||||
id="finding-reproduced-badge"
|
||||
class="bg-ink text-ground"
|
||||
>
|
||||
Reproduced
|
||||
</.notch_badge>
|
||||
<.notch_badge id="finding-disclosure-badge" class="bg-ink text-ground">
|
||||
{TarakanWeb.FindingPresentation.disclosure_badge(@scan.visibility, @scan.provenance)}
|
||||
</.notch_badge>
|
||||
</div>
|
||||
|
||||
<%!-- The submitter's severity claim and a rubric-scored second opinion are
|
||||
different statements. Showing only one of them hides a disagreement
|
||||
that is itself information about the submitter. --%>
|
||||
<p
|
||||
:if={
|
||||
canonical && canonical.calibrated_severity &&
|
||||
canonical.calibrated_severity != @finding.severity
|
||||
}
|
||||
id="finding-severity-calibration"
|
||||
class="mt-2 font-mono text-[11px] text-ink-faint"
|
||||
>
|
||||
submitter said <span class="text-ink-muted">{@finding.severity}</span>
|
||||
· rescored <span class="text-ink">{canonical.calibrated_severity}</span>
|
||||
<span :if={canonical.severity_rubric}>({canonical.severity_rubric})</span>
|
||||
</p>
|
||||
|
||||
<p
|
||||
id="finding-evidence-line"
|
||||
class="mt-2 flex flex-wrap items-center gap-x-1.5 font-mono text-[11px] text-ink-faint"
|
||||
>
|
||||
<span class="text-ink-muted" title={@scan.review_status}>
|
||||
{review_kind_label(@scan.review_kind)}
|
||||
</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{TarakanWeb.FindingPresentation.status_blurb(@scan.review_status)}</span>
|
||||
<span :if={@finding.canonical_finding} id="finding-canonical-memory">
|
||||
<span aria-hidden="true">·</span>
|
||||
detected in {@finding.canonical_finding.detections_count}
|
||||
{if @finding.canonical_finding.detections_count == 1, do: "run", else: "runs"}
|
||||
</span>
|
||||
<span :if={@finding_trust.agent_reproduced} id="finding-trust-agent">
|
||||
<span aria-hidden="true">·</span> agent reproduced
|
||||
</span>
|
||||
<span
|
||||
:if={@finding_trust.agent_disputed}
|
||||
id="finding-trust-agent-disputed"
|
||||
class="text-signal"
|
||||
>
|
||||
<span aria-hidden="true">·</span> agent disputed
|
||||
</span>
|
||||
<span :if={@finding_trust.human_checked} id="finding-trust-human">
|
||||
<span aria-hidden="true">·</span> human checked
|
||||
</span>
|
||||
<span :if={canonical && canonical.reproductions_count > 0} id="finding-reproductions">
|
||||
<span aria-hidden="true">·</span> reproduced {canonical.reproductions_count}
|
||||
{if canonical.reproductions_count == 1, do: "time", else: "times"}
|
||||
</span>
|
||||
<%!-- Surviving attacks is a stronger claim than collecting agreements, so
|
||||
it is reported as attempts survived rather than as a confirmation. --%>
|
||||
<span :if={canonical && canonical.refutations_count > 0} id="finding-refutations">
|
||||
<span aria-hidden="true">·</span>
|
||||
survived {canonical.survived_refutations_count} of {canonical.refutations_count}
|
||||
{if canonical.refutations_count == 1, do: "attack", else: "attacks"}
|
||||
</span>
|
||||
<span :if={cwe_url || cve_url} aria-hidden="true">·</span>
|
||||
<.link
|
||||
:if={cwe_url}
|
||||
id="finding-cwe-badge"
|
||||
href={cwe_url}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="text-ink-muted underline decoration-rule underline-offset-2 transition hover:text-signal"
|
||||
>
|
||||
{canonical.cwe_id}
|
||||
</.link>
|
||||
<.link
|
||||
:if={cve_url}
|
||||
id="finding-cve-badge"
|
||||
href={cve_url}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="text-ink-muted underline decoration-rule underline-offset-2 transition hover:text-signal"
|
||||
>
|
||||
{canonical.cve_id}
|
||||
</.link>
|
||||
</p>
|
||||
|
||||
<%!-- Title + vote --%>
|
||||
<div class="mt-4 flex items-start gap-3">
|
||||
<.vote_control
|
||||
subject_type="canonical_finding"
|
||||
subject_id={@finding.canonical_finding.id}
|
||||
summary={@finding_votes}
|
||||
can_vote={@can_vote}
|
||||
class="mt-1 shrink-0"
|
||||
/>
|
||||
<h1
|
||||
id="finding-title"
|
||||
class="min-w-0 break-words font-display text-2xl font-medium leading-tight tracking-[0.02em] text-ink sm:text-3xl"
|
||||
>
|
||||
{@finding.title}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex min-w-0 flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<.link
|
||||
id="finding-source-link"
|
||||
navigate={~p"/findings/#{@finding.public_id}/code"}
|
||||
class="inline-flex max-w-full min-w-0 items-center gap-1.5 border border-strong bg-panel px-3 py-2 font-mono text-xs text-ink transition hover:border-signal hover:text-signal"
|
||||
aria-label={"Open #{@finding.file_path} at the finding's pinned commit"}
|
||||
>
|
||||
<span class="min-w-0 break-all">
|
||||
{@finding.file_path}{finding_lines(@finding)}
|
||||
</span>
|
||||
<.icon name="hero-arrow-right" class="size-3.5 shrink-0" />
|
||||
</.link>
|
||||
<span class="font-mono text-[11px] text-ink-faint" title={@scan.commit_sha}>
|
||||
@ {String.slice(@scan.commit_sha || "", 0, 7)}
|
||||
</span>
|
||||
<button
|
||||
id="finding-copy-link"
|
||||
type="button"
|
||||
phx-hook="CopyLink"
|
||||
data-copy-text={@public_url}
|
||||
data-copied-label="Copied"
|
||||
class="inline-flex items-center gap-1.5 border border-strong px-3 py-2 font-mono text-[11px] uppercase tracking-[0.12em] text-ink-muted transition hover:border-signal hover:text-signal"
|
||||
>
|
||||
<.icon name="hero-link" class="size-3.5" />
|
||||
<span data-copy-label>Copy link</span>
|
||||
</button>
|
||||
</div>
|
||||
<p id="finding-cite" class="mt-2 max-w-3xl break-all font-mono text-[10px] text-ink-faint">
|
||||
{@public_url}
|
||||
</p>
|
||||
|
||||
<%!-- What / why (main content) --%>
|
||||
<% structured = TarakanWeb.FindingPresentation.structure_description(@finding.description) %>
|
||||
<section id="finding-description" class="mt-8 max-w-3xl">
|
||||
<h2 class="font-display text-sm uppercase tracking-[0.12em] text-ink-faint">
|
||||
What & why
|
||||
</h2>
|
||||
<div class="mt-3 space-y-4">
|
||||
<p
|
||||
:if={structured.lead != ""}
|
||||
class="whitespace-pre-line border-l-2 border-signal pl-4 text-sm leading-7 text-ink"
|
||||
phx-no-format
|
||||
>{structured.lead}</p>
|
||||
<div
|
||||
:for={{label, body} <- structured.sections}
|
||||
class="bg-panel px-4 py-3"
|
||||
>
|
||||
<h3 class="text-sm font-semibold text-ink">
|
||||
{label}
|
||||
</h3>
|
||||
<p class="mt-1.5 whitespace-pre-line text-sm leading-6 text-ink-muted" phx-no-format>{body}</p>
|
||||
</div>
|
||||
<p
|
||||
:if={structured.lead == "" and structured.sections == []}
|
||||
class="whitespace-pre-line border-l-2 border-signal pl-4 text-sm leading-7 text-ink-muted"
|
||||
phx-no-format
|
||||
>{@finding.description}</p>
|
||||
<div
|
||||
:if={canonical.reproduction_steps}
|
||||
id="finding-reproduction"
|
||||
class="bg-panel px-4 py-3"
|
||||
>
|
||||
<h3 class="text-sm font-semibold text-ink">
|
||||
Reproduction
|
||||
</h3>
|
||||
<pre
|
||||
class="mt-1.5 overflow-x-auto whitespace-pre-wrap font-mono text-xs leading-6 text-ink-muted"
|
||||
phx-no-format
|
||||
>{canonical.reproduction_steps}</pre>
|
||||
</div>
|
||||
<div
|
||||
:if={canonical.affected_versions}
|
||||
id="finding-affected-versions"
|
||||
class="bg-panel px-4 py-3"
|
||||
>
|
||||
<h3 class="text-sm font-semibold text-ink">
|
||||
Affected versions
|
||||
</h3>
|
||||
<p class="mt-1.5 font-mono text-xs leading-6 text-ink-muted">
|
||||
{canonical.affected_versions}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<%!-- At a glance meta --%>
|
||||
<section id="finding-review-record" class="mt-8">
|
||||
<h2 class="font-display text-sm uppercase tracking-[0.12em] text-ink-faint">
|
||||
At a glance
|
||||
</h2>
|
||||
<div class="mt-3 grid gap-px border-2 border-strong bg-rule sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div class="bg-ground px-4 py-3">
|
||||
<p class="text-xs font-medium text-ink-faint">Commit</p>
|
||||
<p class="mt-1 font-mono text-sm tabular-nums text-ink" title={@scan.commit_sha}>
|
||||
{String.slice(@scan.commit_sha || "", 0, 12)}
|
||||
</p>
|
||||
<p :if={@scan.commit_committed_at} class="mt-0.5 font-mono text-[10px] text-ink-faint">
|
||||
{record_time(@scan.commit_committed_at)}
|
||||
</p>
|
||||
</div>
|
||||
<div class="bg-ground px-4 py-3">
|
||||
<p class="text-xs font-medium text-ink-faint">How made</p>
|
||||
<p id="finding-provenance" class="mt-1 text-sm text-ink">
|
||||
{TarakanWeb.FindingPresentation.how_made_label(@scan.provenance)}
|
||||
<span class="text-ink-faint"> (claim)</span>
|
||||
</p>
|
||||
<p :if={@scan.model} class="mt-0.5 font-mono text-[10px] text-ink-faint">
|
||||
{@scan.model}
|
||||
<span :if={@scan.prompt_version}> · {@scan.prompt_version}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="bg-ground px-4 py-3">
|
||||
<p class="text-xs font-medium text-ink-faint">
|
||||
Submitted
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-ink">
|
||||
<.handle_link handle={@scan.submitted_by.handle} />
|
||||
</p>
|
||||
<p class="mt-0.5 font-mono text-[10px] text-ink-faint">
|
||||
{record_time(@scan.inserted_at)}
|
||||
</p>
|
||||
</div>
|
||||
<%!--
|
||||
One first finder per issue, permanently. Later reports corroborate
|
||||
it but never displace the credit.
|
||||
--%>
|
||||
<div :if={@first_finder} id="finding-first-finder" class="bg-ground px-4 py-3">
|
||||
<p class="text-xs font-medium text-ink-faint">First found by</p>
|
||||
<p class="mt-1 text-sm text-ink">
|
||||
<.handle_link handle={@first_finder.handle} />
|
||||
</p>
|
||||
<p class="mt-0.5 font-mono text-[10px] text-ink-faint">
|
||||
{record_time(@first_finder.reported_at)} · {String.slice(
|
||||
@first_finder.commit_sha || "",
|
||||
0,
|
||||
7
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div class="bg-ground px-4 py-3">
|
||||
<p class="text-xs font-medium text-ink-faint">Checks</p>
|
||||
<p id="finding-verdict-counts" class="mt-1 font-mono text-sm text-ink">
|
||||
{@finding.canonical_finding.confirmations_count} confirmed · {@finding.canonical_finding.disputes_count} disputed
|
||||
</p>
|
||||
<p class="mt-0.5 font-mono text-[10px] text-ink-faint">
|
||||
status {@finding.canonical_finding.status}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:if={@review_stake || @scan.reviewed_at || @scan.notes}
|
||||
class="mt-3 space-y-2 text-xs text-ink-muted"
|
||||
>
|
||||
<p :if={@review_stake} id="finding-stake" class="font-mono">
|
||||
<span class="text-ink-faint">Stake</span>
|
||||
<span class={[
|
||||
"ml-2 tabular-nums",
|
||||
@review_stake.status == :slashed && "text-signal",
|
||||
@review_stake.status == :returned && "text-quote",
|
||||
@review_stake.status == :at_risk && "text-ink"
|
||||
]}>
|
||||
{@review_stake.amount} · {stake_label(@review_stake.status)}
|
||||
</span>
|
||||
</p>
|
||||
<p :if={@scan.reviewed_at} class="font-mono text-ink-faint">
|
||||
Disclosed {record_time(@scan.reviewed_at)}
|
||||
</p>
|
||||
<div :if={@scan.notes} class="max-w-3xl border border-rule bg-panel px-3 py-2">
|
||||
<p class="text-xs font-semibold text-ink-muted">
|
||||
Report summary
|
||||
</p>
|
||||
<% notes = TarakanWeb.FindingPresentation.humanize_notes(@scan.notes) %>
|
||||
<div :if={notes && notes.kind == :summary} class="mt-1">
|
||||
<p class="text-ink">
|
||||
{notes.count} {if notes.count == 1, do: "finding", else: "findings"} in this report.
|
||||
</p>
|
||||
<ol :if={notes.tops != []} class="mt-1 list-decimal space-y-0.5 pl-5 text-ink-muted">
|
||||
<li :for={top <- notes.tops}>{top}</li>
|
||||
</ol>
|
||||
</div>
|
||||
<p
|
||||
:if={notes && notes.kind == :plain}
|
||||
class="mt-1 whitespace-pre-line text-ink-muted"
|
||||
phx-no-format
|
||||
>{notes.text}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<%!-- Lifecycle timeline --%>
|
||||
<% events = TarakanWeb.FindingPresentation.timeline_events(canonical, @finding_checks) %>
|
||||
<section id="finding-timeline" class="mt-8 max-w-3xl">
|
||||
<h2 class="font-display text-sm uppercase tracking-[0.12em] text-ink-faint">
|
||||
Lifecycle
|
||||
</h2>
|
||||
<ol class="mt-3 border-l-2 border-rule">
|
||||
<li
|
||||
:for={event <- events}
|
||||
id={"finding-timeline-#{event.id}"}
|
||||
class="relative pb-4 pl-4 last:pb-0"
|
||||
>
|
||||
<span class="absolute top-1.5 -left-[5px] size-2 bg-ink-faint"></span>
|
||||
<p class="text-sm leading-5 text-ink">
|
||||
{event.title}
|
||||
<span
|
||||
:if={event.detail}
|
||||
class="font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint"
|
||||
>
|
||||
· {event.detail}
|
||||
</span>
|
||||
</p>
|
||||
<p class="mt-0.5 font-mono text-[10px] text-ink-faint">
|
||||
{record_time(event.at)}
|
||||
</p>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<.form
|
||||
:if={@can_record_vendor_notification}
|
||||
for={@vendor_form}
|
||||
id="finding-vendor-form"
|
||||
phx-submit="record_vendor_notification"
|
||||
class="mt-4 flex flex-wrap items-end gap-2 border border-strong bg-panel px-4 py-3"
|
||||
>
|
||||
<.input
|
||||
field={@vendor_form[:notified_on]}
|
||||
type="date"
|
||||
label="Vendor notified on (empty clears)"
|
||||
class="border border-strong bg-ground px-3 py-2 font-mono text-xs text-ink placeholder:text-ink-faint focus:border-signal focus:outline-none"
|
||||
/>
|
||||
<.button size="sm">Record</.button>
|
||||
</.form>
|
||||
</section>
|
||||
|
||||
<%!-- Independent checks --%>
|
||||
<section id="finding-verification" class="mt-10">
|
||||
<div class="flex flex-wrap items-end justify-between gap-3 border-b-2 border-strong pb-3">
|
||||
<h2 class="font-display text-lg uppercase tracking-[0.02em] text-ink">
|
||||
Checks
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<%!-- Hidden duplicate for tests that query counts only inside verification; primary counts live in glance grid --%>
|
||||
<div class="sr-only" aria-hidden="true">
|
||||
{@finding.canonical_finding.confirmations_count} confirmed · {@finding.canonical_finding.disputes_count} disputed
|
||||
</div>
|
||||
|
||||
<p
|
||||
:if={@finding_checks == []}
|
||||
id="finding-no-checks"
|
||||
class="py-6 text-center text-sm text-ink-faint"
|
||||
>
|
||||
No checks at this commit yet.
|
||||
</p>
|
||||
|
||||
<div
|
||||
:if={@finding_checks != []}
|
||||
id="finding-checks"
|
||||
class="divide-y divide-rule border-y border-rule"
|
||||
>
|
||||
<article :for={check <- @finding_checks} id={"finding-check-#{check.id}"} class="py-4">
|
||||
<div class="flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
||||
<.handle_link handle={check.account.handle} class="font-mono text-xs font-semibold" />
|
||||
<span class={[
|
||||
"font-mono text-[10px] uppercase tracking-[0.12em]",
|
||||
check.verdict == "confirmed" && "text-quote",
|
||||
check.verdict == "disputed" && "text-signal",
|
||||
check.verdict == "fixed" && "text-quote"
|
||||
]}>
|
||||
{check.verdict}
|
||||
</span>
|
||||
<span class="font-mono text-[10px] text-ink-faint">
|
||||
· {provenance_label(check.provenance)} · {record_time(check.inserted_at)}
|
||||
</span>
|
||||
<span class={[
|
||||
"ml-auto font-mono text-[10px] uppercase tracking-[0.12em]",
|
||||
check.counts_toward_quorum && "text-ink-muted",
|
||||
!check.counts_toward_quorum && "text-ink-faint"
|
||||
]}>
|
||||
{if check.counts_toward_quorum,
|
||||
do: "counts toward quorum",
|
||||
else: "corroboration only"}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-2 whitespace-pre-line text-sm leading-6 text-ink-muted" phx-no-format>{check.notes}</p>
|
||||
<details :if={check.evidence} class="mt-2">
|
||||
<summary class="cursor-pointer font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint hover:text-ink">
|
||||
Evidence
|
||||
</summary>
|
||||
<pre class="mt-2 overflow-x-auto border border-rule bg-panel px-3 py-2 font-mono text-[11px] leading-5 text-ink-muted">{check.evidence}</pre>
|
||||
</details>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<%!-- Queue an agent instead of doing it yourself. Each of these asks for
|
||||
a different kind of evidence than "somebody agreed": an attack that
|
||||
failed, a reproduction that ran, a score against a rubric. --%>
|
||||
<div
|
||||
:if={@can_open_finding_job}
|
||||
id="finding-agent-jobs"
|
||||
class="mt-5 flex flex-wrap items-center gap-2"
|
||||
>
|
||||
<span class="mr-1 font-mono text-[11px] uppercase tracking-[0.12em] text-ink-faint">
|
||||
Open a job
|
||||
</span>
|
||||
<.button
|
||||
id="finding-job-refute"
|
||||
type="button"
|
||||
size="sm"
|
||||
phx-click="open_finding_job"
|
||||
phx-value-kind="refute_finding"
|
||||
>
|
||||
Refute
|
||||
</.button>
|
||||
<.button
|
||||
id="finding-job-reproduce"
|
||||
type="button"
|
||||
size="sm"
|
||||
phx-click="open_finding_job"
|
||||
phx-value-kind="reproduce_finding"
|
||||
>
|
||||
Reproduce
|
||||
</.button>
|
||||
<.button
|
||||
id="finding-job-calibrate"
|
||||
type="button"
|
||||
size="sm"
|
||||
phx-click="open_finding_job"
|
||||
phx-value-kind="calibrate_severity"
|
||||
>
|
||||
Re-score
|
||||
</.button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:if={@can_check}
|
||||
id="finding-verdict"
|
||||
class="mt-5 border border-strong bg-panel px-4 py-4"
|
||||
>
|
||||
<h3 class="text-sm font-semibold text-ink">
|
||||
Record a check
|
||||
</h3>
|
||||
<form
|
||||
id="finding-verdict-form"
|
||||
phx-submit="record_finding_verdict"
|
||||
class="mt-2 flex flex-wrap gap-2"
|
||||
>
|
||||
<input
|
||||
id="finding-verdict-notes"
|
||||
type="text"
|
||||
name="notes"
|
||||
required
|
||||
minlength="20"
|
||||
maxlength="2000"
|
||||
placeholder="Evidence notes (required, 20+ chars)"
|
||||
autocomplete="off"
|
||||
class="min-w-0 flex-1 basis-48 border border-strong bg-ground px-3 py-2 font-mono text-xs text-ink placeholder:text-ink-faint focus:border-signal focus:outline-none"
|
||||
/>
|
||||
<.button name="verdict" value="confirmed" size="sm">Confirm</.button>
|
||||
<.button name="verdict" value="disputed" variant="danger" size="sm">
|
||||
Dispute
|
||||
</.button>
|
||||
<.button name="verdict" value="fixed" size="sm">Fixed</.button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<.link
|
||||
:if={is_nil(@current_scope) or is_nil(@current_scope.account)}
|
||||
id="finding-verify-login"
|
||||
navigate={~p"/accounts/log-in"}
|
||||
class="mt-4 block font-mono text-xs text-signal hover:underline"
|
||||
>
|
||||
Sign in to confirm or dispute this finding.
|
||||
</.link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<%!-- Discussion --%>
|
||||
<section id="finding-discussion" class="mt-10">
|
||||
<div class="flex flex-wrap items-baseline justify-between gap-2 border-b-2 border-strong pb-3">
|
||||
<h2 class="font-display text-lg uppercase tracking-[0.02em] text-ink">
|
||||
Discussion
|
||||
</h2>
|
||||
<span id="finding-comment-count" class="font-mono text-[10px] tabular-nums text-ink-faint">
|
||||
{@comment_count} {if @comment_count == 1, do: "comment", else: "comments"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<form
|
||||
:if={@current_scope && @current_scope.account}
|
||||
id="finding-comment-form"
|
||||
phx-submit="post_comment"
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<textarea
|
||||
id="finding-comment-body"
|
||||
name="body"
|
||||
rows="3"
|
||||
placeholder="Add evidence, a counter-argument, or context…"
|
||||
class="w-full border border-strong bg-transparent px-3 py-2 font-mono text-xs text-ink placeholder:text-ink-faint focus:outline-none focus:ring-2 focus:ring-phosphor"
|
||||
></textarea>
|
||||
<div>
|
||||
<.button variant="primary" size="sm">Post comment</.button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<.link
|
||||
:if={is_nil(@current_scope) or is_nil(@current_scope.account)}
|
||||
id="finding-comment-login"
|
||||
navigate={~p"/accounts/log-in"}
|
||||
class="block font-mono text-xs text-signal hover:underline"
|
||||
>
|
||||
Sign in to join the discussion.
|
||||
</.link>
|
||||
|
||||
<p
|
||||
:if={@comments == []}
|
||||
id="finding-no-comments"
|
||||
class="mt-6 text-center font-mono text-xs text-ink-faint"
|
||||
>
|
||||
No comments yet.
|
||||
</p>
|
||||
|
||||
<div :if={@comments != []} class="mt-5 space-y-4">
|
||||
<.comment_thread
|
||||
:for={comment <- @comments}
|
||||
comment={comment}
|
||||
reply_to={@reply_to}
|
||||
can_reply={!is_nil(@current_scope) and !is_nil(@current_scope.account)}
|
||||
can_moderate={@can_moderate_comments}
|
||||
can_vote={@can_vote}
|
||||
votes={@comment_votes}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<%!-- Technical dump last --%>
|
||||
<details
|
||||
:if={@scan.raw_document}
|
||||
id="finding-raw-report"
|
||||
class="mt-10 border-2 border-strong"
|
||||
>
|
||||
<summary class="cursor-pointer bg-panel px-4 py-3 font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint transition hover:text-ink">
|
||||
Technical · raw report JSON
|
||||
<span class="ml-2 normal-case tracking-normal text-ink-faint/80">
|
||||
full report, not only this finding
|
||||
</span>
|
||||
</summary>
|
||||
<pre class="max-h-[24rem] overflow-auto border-t border-rule px-4 py-4 font-mono text-[11px] leading-5 text-ink-muted">{raw_report(@scan)}</pre>
|
||||
</details>
|
||||
|
||||
<p class="mt-8 font-mono text-xs">
|
||||
<.link
|
||||
id="finding-record-link"
|
||||
navigate={TarakanWeb.RepositoryPaths.repository_security_path(@repository)}
|
||||
class="text-signal transition hover:underline"
|
||||
>
|
||||
← All findings for {@repository.owner}/{@repository.name}
|
||||
</.link>
|
||||
</p>
|
||||
</Layouts.page>
|
||||
</Layouts.app>
|
||||
119
lib/tarakan_web/live/infestation_live/index.ex
Normal file
119
lib/tarakan_web/live/infestation_live/index.ex
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
defmodule TarakanWeb.InfestationLive.Index do
|
||||
@moduledoc "Cross-repo infestation list: patterns spanning multiple listed repositories."
|
||||
use TarakanWeb, :live_view
|
||||
|
||||
alias Tarakan.Infestations
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Infestations")
|
||||
|> assign(
|
||||
:meta_description,
|
||||
"Cross-repo infestations: the same finding title in multiple listed repositories."
|
||||
)
|
||||
|> assign(:canonical_path, ~p"/infestations")
|
||||
|> assign(:days, 30)
|
||||
|> assign(:min_repos, 2)
|
||||
|> load_infestations()}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("filter", params, socket) do
|
||||
days = parse_int(params["days"], socket.assigns.days, 1, 365)
|
||||
min_repos = parse_int(params["min_repos"], socket.assigns.min_repos, 2, 20)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:days, days)
|
||||
|> assign(:min_repos, min_repos)
|
||||
|> load_infestations()}
|
||||
end
|
||||
|
||||
defp load_infestations(socket) do
|
||||
infestations =
|
||||
Infestations.list_infestations(
|
||||
days: socket.assigns.days,
|
||||
min_repos: socket.assigns.min_repos,
|
||||
limit: 50
|
||||
)
|
||||
|
||||
assign(socket, :infestations, infestations)
|
||||
end
|
||||
|
||||
defp parse_int(value, default, min, max) do
|
||||
case Integer.parse(to_string(value || "")) do
|
||||
{n, _} -> n |> max(min) |> min(max)
|
||||
:error -> default
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
|
||||
<Layouts.page width={:wide}>
|
||||
<div class="flex flex-col gap-4 border-b-2 border-strong pb-6 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div class="max-w-2xl">
|
||||
<h1 class="font-display text-3xl font-medium uppercase tracking-[0.02em] text-ink sm:text-5xl">
|
||||
Infestations
|
||||
</h1>
|
||||
<p class="mt-3 text-sm leading-6 text-ink-muted">
|
||||
Same finding title in {@min_repos}+ listed repos within the window.
|
||||
Ranked by affected repo count.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
id="infestations-filter"
|
||||
phx-change="filter"
|
||||
class="flex flex-wrap items-end gap-3 font-mono text-xs"
|
||||
>
|
||||
<label class="flex flex-col gap-1 text-ink-faint">
|
||||
Min repos
|
||||
<select
|
||||
name="min_repos"
|
||||
class="border border-strong bg-panel px-2 py-1.5 text-ink"
|
||||
>
|
||||
<option :for={n <- [2, 3, 5, 10]} value={n} selected={@min_repos == n}>{n}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-ink-faint">
|
||||
Window
|
||||
<select name="days" class="border border-strong bg-panel px-2 py-1.5 text-ink">
|
||||
<option :for={n <- [7, 30, 90, 365]} value={n} selected={@days == n}>
|
||||
{n}d
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:if={@infestations == []}
|
||||
id="infestations-empty"
|
||||
class="mt-10 bg-panel px-6 py-16 text-center"
|
||||
>
|
||||
<p class="text-sm font-medium text-ink">No infestations in this window</p>
|
||||
<p class="mt-2 text-xs text-ink-muted">
|
||||
Infestations appear when the same finding title hits {@min_repos}+ listed repos.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div :if={@infestations != []} class="mt-8">
|
||||
<.constellation id="infestations-constellation" infestations={@infestations} />
|
||||
|
||||
<ul id="infestations-list" class="sr-only">
|
||||
<li :for={infestation <- @infestations} id={"infestation-#{infestation.pattern_key}"}>
|
||||
<.link navigate={~p"/infestations/#{infestation.pattern_key}"}>
|
||||
{infestation.title} · {infestation.repo_count} repos
|
||||
</.link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Layouts.page>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
end
|
||||
343
lib/tarakan_web/live/infestation_live/show.ex
Normal file
343
lib/tarakan_web/live/infestation_live/show.ex
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
defmodule TarakanWeb.InfestationLive.Show do
|
||||
@moduledoc "One cross-repo infestation pattern: affected repos and instances."
|
||||
use TarakanWeb, :live_view
|
||||
|
||||
alias Tarakan.Fixes
|
||||
alias Tarakan.Infestations
|
||||
alias Tarakan.Policy
|
||||
alias TarakanWeb.RepositoryPaths
|
||||
|
||||
@graph_limit 64
|
||||
@ledger_page 50
|
||||
|
||||
@impl true
|
||||
def mount(%{"pattern_key" => pattern_key}, _session, socket) do
|
||||
infestation = Infestations.get_infestation(pattern_key)
|
||||
|
||||
if is_nil(infestation) or infestation.repo_count < 1 do
|
||||
raise Ecto.NoResultsError, queryable: Tarakan.Scans.CanonicalFinding
|
||||
end
|
||||
|
||||
graph = Infestations.list_pattern_repos_page(pattern_key, limit: @graph_limit)
|
||||
ledger = Infestations.list_instances_page(pattern_key, limit: @ledger_page)
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, infestation.title)
|
||||
|> assign(
|
||||
:meta_description,
|
||||
"#{infestation.title}. Seen in #{infestation.repo_count} listed repositories on Tarakan."
|
||||
)
|
||||
|> assign(:canonical_path, ~p"/infestations/#{pattern_key}")
|
||||
|> assign(:pattern_key, pattern_key)
|
||||
|> assign(:infestation, infestation)
|
||||
|> assign(:open_bounties, Tarakan.Market.open_bounties_for_target(:infestation, pattern_key))
|
||||
|> assign(:graph_repos, graph.entries)
|
||||
|> assign(:graph_hidden, max(infestation.repo_count - length(graph.entries), 0))
|
||||
|> stream(:ledger_instances, ledger.entries, reset: true)
|
||||
|> assign(:ledger_cursor, ledger.next_cursor)
|
||||
|> assign(:ledger_empty?, ledger.entries == [])
|
||||
|> assign(
|
||||
:can_moderate,
|
||||
Policy.allowed?(socket.assigns.current_scope, :moderate)
|
||||
)
|
||||
|> load_fix_template(pattern_key)}
|
||||
end
|
||||
|
||||
# A pattern has a fix template only once one of its instances has actually
|
||||
# been fixed, so the whole section is absent until then.
|
||||
defp load_fix_template(socket, pattern_key) do
|
||||
case Fixes.latest_template(pattern_key) do
|
||||
nil ->
|
||||
socket
|
||||
|> assign(:fix_template, nil)
|
||||
|> assign(:propagations, [])
|
||||
|> assign(:propagation_targets, 0)
|
||||
|
||||
template ->
|
||||
socket
|
||||
|> assign(:fix_template, template)
|
||||
|> assign(:propagations, Fixes.list_propagations(template))
|
||||
|> assign(:propagation_targets, length(Fixes.propagation_targets(template)))
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("propagate_fix", _params, socket) do
|
||||
template = socket.assigns.fix_template
|
||||
|
||||
cond do
|
||||
not socket.assigns.can_moderate ->
|
||||
{:noreply, put_flash(socket, :error, "Not authorized.")}
|
||||
|
||||
is_nil(template) ->
|
||||
{:noreply, put_flash(socket, :error, "No captured fix to propagate.")}
|
||||
|
||||
true ->
|
||||
case Fixes.propagate(socket.assigns.current_scope, template) do
|
||||
{:ok, %{opened: opened, failed: failed}} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Opened #{opened} fix job(s). Failed #{failed}.")
|
||||
|> load_fix_template(socket.assigns.pattern_key)}
|
||||
|
||||
{:error, :unauthorized} ->
|
||||
{:noreply, put_flash(socket, :error, "Not authorized.")}
|
||||
|
||||
{:error, _reason} ->
|
||||
{:noreply, put_flash(socket, :error, "Could not open fix jobs.")}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("load_more_instances", _params, socket) do
|
||||
case socket.assigns.ledger_cursor do
|
||||
nil ->
|
||||
{:noreply, socket}
|
||||
|
||||
cursor ->
|
||||
page =
|
||||
Infestations.list_instances_page(socket.assigns.pattern_key,
|
||||
limit: @ledger_page,
|
||||
cursor: cursor
|
||||
)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> stream(:ledger_instances, page.entries)
|
||||
|> assign(:ledger_cursor, page.next_cursor)}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
|
||||
<Layouts.page width={:wide}>
|
||||
<.breadcrumbs id="infestation-breadcrumb">
|
||||
<:crumb navigate={~p"/"}>registry</:crumb>
|
||||
<:crumb navigate={~p"/infestations"}>infestations</:crumb>
|
||||
<:crumb>pattern</:crumb>
|
||||
</.breadcrumbs>
|
||||
|
||||
<TarakanWeb.BountyComponents.wanted_banner bounties={@open_bounties} />
|
||||
|
||||
<div class="mt-4 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div class="min-w-0 max-w-3xl">
|
||||
<h1 class="font-display text-2xl font-medium leading-tight tracking-[0.02em] text-ink sm:text-4xl">
|
||||
{@infestation.title}
|
||||
</h1>
|
||||
<p
|
||||
:if={@infestation.severity}
|
||||
class="mt-2 font-mono text-xs text-signal"
|
||||
>
|
||||
{@infestation.severity}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid gap-px border-2 border-strong bg-rule sm:grid-cols-4">
|
||||
<div class="bg-ground px-4 py-4">
|
||||
<p class="text-xs font-medium text-ink-faint">Repos</p>
|
||||
<p id="infestation-repo-count" class="mt-1 font-display text-3xl tabular-nums text-ink">
|
||||
{@infestation.repo_count}
|
||||
</p>
|
||||
</div>
|
||||
<div class="bg-ground px-4 py-4">
|
||||
<p class="text-xs font-medium text-ink-faint">Open</p>
|
||||
<p class="mt-1 font-display text-3xl tabular-nums text-phosphor">
|
||||
{@infestation.open_count}
|
||||
</p>
|
||||
</div>
|
||||
<div class="bg-ground px-4 py-4">
|
||||
<p class="text-xs font-medium text-ink-faint">Verified</p>
|
||||
<p class="mt-1 font-display text-3xl tabular-nums text-ink">
|
||||
{@infestation.verified_count}
|
||||
</p>
|
||||
</div>
|
||||
<div class="bg-ground px-4 py-4">
|
||||
<p class="text-xs font-medium text-ink-faint">Fixed</p>
|
||||
<p class="mt-1 font-display text-3xl tabular-nums text-ink-muted">
|
||||
{@infestation.fixed_count}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!--
|
||||
The registry's payoff: one repository fixed this, so every other
|
||||
repository carrying the pattern can start from that fix.
|
||||
--%>
|
||||
<section
|
||||
:if={@fix_template}
|
||||
id="infestation-fix-template"
|
||||
class="mt-8 border-2 border-phosphor"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-3 border-b border-rule px-4 py-3">
|
||||
<h2 class="min-w-0 flex-1 font-display text-sm uppercase tracking-[0.12em] text-phosphor">
|
||||
Fixed on
|
||||
<.link
|
||||
navigate={RepositoryPaths.repository_path(@fix_template.source_repository)}
|
||||
class="hover:underline"
|
||||
>
|
||||
{@fix_template.source_repository.owner}/{@fix_template.source_repository.name}
|
||||
</.link>
|
||||
</h2>
|
||||
<span
|
||||
:if={@propagations != []}
|
||||
class="font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint"
|
||||
>
|
||||
carried to {length(@propagations)} repo(s)
|
||||
</span>
|
||||
<button
|
||||
:if={@can_moderate and @propagation_targets > 0}
|
||||
id="infestation-propagate-fix"
|
||||
type="button"
|
||||
phx-click="propagate_fix"
|
||||
data-confirm={"Open fix jobs on #{@propagation_targets} repository(s) still carrying this pattern?"}
|
||||
class="shrink-0 bg-btn px-3 py-1.5 font-mono text-[11px] uppercase tracking-[0.12em] text-btn-fg transition hover:opacity-90"
|
||||
>
|
||||
Propagate to {@propagation_targets}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="whitespace-pre-line px-4 py-3 text-sm leading-6 text-ink-muted" phx-no-format>{String.slice(@fix_template.summary, 0, 600)}</p>
|
||||
|
||||
<ul :if={@propagations != []} class="divide-y divide-rule border-t border-rule">
|
||||
<li
|
||||
:for={propagation <- @propagations}
|
||||
id={"propagation-#{propagation.id}"}
|
||||
class="flex flex-wrap items-baseline gap-x-3 gap-y-1 px-4 py-2.5"
|
||||
>
|
||||
<.link
|
||||
navigate={RepositoryPaths.repository_path(propagation.repository)}
|
||||
class="min-w-0 flex-1 truncate font-mono text-xs text-ink hover:text-signal hover:underline"
|
||||
>
|
||||
{propagation.repository.owner}/{propagation.repository.name}
|
||||
</.link>
|
||||
<.link
|
||||
:if={propagation.review_task}
|
||||
navigate={~p"/jobs/#{propagation.review_task.id}"}
|
||||
class="shrink-0 font-mono text-[11px] text-signal hover:underline"
|
||||
>
|
||||
Job →
|
||||
</.link>
|
||||
<span class={[
|
||||
"shrink-0 font-mono text-[10px] uppercase tracking-[0.12em]",
|
||||
propagation.status == "fixed" && "text-phosphor",
|
||||
propagation.status == "stale" && "text-ink-faint",
|
||||
propagation.status == "open" && "text-ink-muted"
|
||||
]}>
|
||||
{propagation.status}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="mb-3 font-display text-lg uppercase tracking-[0.02em] text-ink">
|
||||
Affected repositories
|
||||
</h2>
|
||||
<.infestation_graph
|
||||
id="infestation-graph"
|
||||
infestation={@infestation}
|
||||
repos={repo_rows(@graph_repos)}
|
||||
pattern_key={@pattern_key}
|
||||
/>
|
||||
<p
|
||||
:if={@graph_hidden > 0}
|
||||
id="infestation-graph-more"
|
||||
class="mt-3 font-mono text-[11px] text-ink-faint"
|
||||
>
|
||||
+{@graph_hidden} more repositories
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="infestation-instances" class="mt-10">
|
||||
<h2 class="mb-3 font-display text-lg uppercase tracking-[0.02em] text-ink">
|
||||
Instances
|
||||
</h2>
|
||||
|
||||
<p :if={@ledger_empty?} class="mt-4 font-mono text-xs text-ink-faint">No instances.</p>
|
||||
|
||||
<ul
|
||||
id="infestation-ledger"
|
||||
phx-update="stream"
|
||||
class="mt-4 divide-y divide-rule border-2 border-strong"
|
||||
>
|
||||
<li
|
||||
:for={{dom_id, instance} <- @streams.ledger_instances}
|
||||
id={dom_id}
|
||||
class="grid gap-2 px-4 py-4 sm:grid-cols-[auto_1fr_auto] sm:items-center sm:px-6"
|
||||
>
|
||||
<span class={[
|
||||
"size-2.5 shrink-0 rounded-full",
|
||||
instance.status == "open" && "bg-phosphor",
|
||||
instance.status == "verified" && "bg-ink",
|
||||
instance.status == "fixed" && "bg-ink-muted",
|
||||
instance.status not in ["open", "verified", "fixed"] && "bg-ink-faint"
|
||||
]}></span>
|
||||
<div class="min-w-0">
|
||||
<p class="font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint">
|
||||
<.link
|
||||
:if={instance[:host]}
|
||||
navigate={RepositoryPaths.repository_path(instance)}
|
||||
class="text-ink-muted hover:text-signal hover:underline"
|
||||
>
|
||||
{instance.owner}/{instance.name}
|
||||
</.link>
|
||||
<span :if={is_nil(instance[:host])}>{instance.owner}/{instance.name}</span>
|
||||
<span class="mx-1">·</span>
|
||||
{instance.status}
|
||||
<span :if={instance[:commit_sha]} class="mx-1">·</span>
|
||||
<span :if={instance[:commit_sha]} title={instance.commit_sha}>
|
||||
{String.slice(instance.commit_sha || "", 0, 7)}
|
||||
</span>
|
||||
</p>
|
||||
<p class="mt-1 truncate font-mono text-xs text-ink-muted">
|
||||
{instance.file_path}
|
||||
</p>
|
||||
</div>
|
||||
<.link
|
||||
:if={instance.occurrence_public_id}
|
||||
navigate={~p"/findings/#{instance.occurrence_public_id}"}
|
||||
class="font-mono text-xs text-signal hover:underline"
|
||||
>
|
||||
Open →
|
||||
</.link>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<button
|
||||
:if={@ledger_cursor}
|
||||
id="infestation-load-more"
|
||||
type="button"
|
||||
phx-click="load_more_instances"
|
||||
class="mt-4 bg-panel px-4 py-2 font-mono text-[11px] uppercase tracking-[0.12em] text-ink transition hover:border-signal"
|
||||
>
|
||||
Load more
|
||||
</button>
|
||||
</section>
|
||||
</Layouts.page>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
||||
# Repo matrix expects instance-shaped maps (status, owner, name, occurrence_public_id, id).
|
||||
# Rows are pattern_repos: one per repository, rolled up from the instances
|
||||
# listed below. instance_count/open_count are what make this table worth
|
||||
# showing separately - without them a repo hit twice looks like one hit once.
|
||||
defp repo_rows(repos) do
|
||||
Enum.map(repos, fn r ->
|
||||
%{
|
||||
id: r.repository_id,
|
||||
host: r.host,
|
||||
owner: r.owner,
|
||||
name: r.name,
|
||||
status: r.status,
|
||||
occurrence_public_id: r.occurrence_public_id,
|
||||
instance_count: r.instance_count,
|
||||
open_count: r.open_count
|
||||
}
|
||||
end)
|
||||
end
|
||||
end
|
||||
132
lib/tarakan_web/live/jobs_live.ex
Normal file
132
lib/tarakan_web/live/jobs_live.ex
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
defmodule TarakanWeb.JobsLive do
|
||||
@moduledoc "Open Jobs queue. Check jobs sort first."
|
||||
use TarakanWeb, :live_view
|
||||
|
||||
alias Tarakan.Work
|
||||
|
||||
@limit 50
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
if connected?(socket) do
|
||||
Tarakan.Activity.subscribe()
|
||||
end
|
||||
|
||||
{:ok, assign_queue(socket)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:activity, _entry}, socket) do
|
||||
{:noreply, assign_queue(socket)}
|
||||
end
|
||||
|
||||
def handle_info(_message, socket), do: {:noreply, socket}
|
||||
|
||||
defp assign_queue(socket) do
|
||||
jobs = Work.list_open_public_tasks(@limit)
|
||||
|
||||
socket
|
||||
|> assign(:page_title, "Jobs")
|
||||
|> assign(
|
||||
:meta_description,
|
||||
"Open Jobs on Tarakan. Check jobs first. Or: tarakan worker --agent kimi"
|
||||
)
|
||||
|> assign(:canonical_path, ~p"/jobs")
|
||||
|> assign(:jobs, jobs)
|
||||
|> assign(:job_count, length(jobs))
|
||||
|> assign(
|
||||
:client_commands,
|
||||
"tarakan login\ntarakan --agent kimi --pickup\ntarakan worker --agent kimi"
|
||||
)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
|
||||
<Layouts.page width={:wide}>
|
||||
<div class="flex flex-col gap-6 border-b-2 border-strong pb-6 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div class="min-w-0 max-w-2xl">
|
||||
<h1 class="font-display text-3xl font-medium uppercase leading-none tracking-[0.02em] text-ink sm:text-5xl">
|
||||
Jobs
|
||||
</h1>
|
||||
<p class="mt-3 text-sm leading-6 text-ink-muted">
|
||||
Claim in the browser or with the client. Checks sort first.
|
||||
No Job needed to publish a <.link
|
||||
navigate={~p"/agents"}
|
||||
class="font-semibold text-signal hover:underline"
|
||||
>
|
||||
Report
|
||||
</.link>.
|
||||
</p>
|
||||
<pre
|
||||
id="jobs-client-commands"
|
||||
class="mt-4 overflow-x-auto bg-panel px-3 py-3 font-mono text-[11px] leading-6 text-ink whitespace-pre sm:px-4 sm:text-xs"
|
||||
><code>{@client_commands}</code></pre>
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-wrap items-center gap-4">
|
||||
<p class="font-mono text-sm tabular-nums text-ink">
|
||||
<span class="font-display text-3xl">{@job_count}</span>
|
||||
<span class="ml-1 text-xs uppercase tracking-[0.12em] text-ink-faint">open</span>
|
||||
</p>
|
||||
<.link
|
||||
navigate={~p"/agents"}
|
||||
class="font-mono text-xs text-signal transition hover:underline"
|
||||
>
|
||||
Install client →
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:if={@jobs == []}
|
||||
id="jobs-empty"
|
||||
class="mt-8 bg-panel px-6 py-12 text-center"
|
||||
>
|
||||
<p class="text-sm font-medium text-ink">Nothing open right now</p>
|
||||
<p class="mt-2 text-xs leading-5 text-ink-muted">
|
||||
<.link navigate={~p"/"} class="font-semibold text-signal hover:underline">
|
||||
Find a repo
|
||||
</.link>
|
||||
or run <code class="font-mono text-ink">tarakan worker --agent kimi</code>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul
|
||||
:if={@jobs != []}
|
||||
id="jobs-list"
|
||||
class="mt-8 divide-y divide-rule border-2 border-strong"
|
||||
>
|
||||
<li :for={job <- @jobs} id={"job-#{job.id}"}>
|
||||
<.link
|
||||
navigate={~p"/jobs/#{job.id}"}
|
||||
class="group grid gap-3 px-4 py-4 transition-colors hover:bg-panel sm:grid-cols-[1fr_auto] sm:items-center sm:px-6 sm:py-5"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<p class="font-mono text-[11px] text-ink-faint">
|
||||
{job.repository.owner}/{job.repository.name}
|
||||
<span class="mx-1">·</span>
|
||||
<span title={job.commit_sha}>{String.slice(job.commit_sha || "", 0, 7)}</span>
|
||||
<span :if={job.kind == "verify_findings"} class="ml-2 text-signal">
|
||||
· check
|
||||
</span>
|
||||
</p>
|
||||
<p class="mt-1.5 text-sm font-semibold leading-5 text-ink group-hover:text-signal">
|
||||
{job.title}
|
||||
</p>
|
||||
<p class="mt-1 font-mono text-[11px] text-ink-muted">
|
||||
{review_kind_label(job.kind)} · {provenance_label(job.capability)} required
|
||||
</p>
|
||||
</div>
|
||||
<.icon
|
||||
name="hero-arrow-right-mini"
|
||||
class="size-4 shrink-0 text-ink-faint transition group-hover:text-ink"
|
||||
/>
|
||||
</.link>
|
||||
</li>
|
||||
</ul>
|
||||
</Layouts.page>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
end
|
||||
105
lib/tarakan_web/live/leaderboard_live.ex
Normal file
105
lib/tarakan_web/live/leaderboard_live.ex
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
defmodule TarakanWeb.LeaderboardLive do
|
||||
use TarakanWeb, :live_view
|
||||
|
||||
alias Tarakan.Leaderboard
|
||||
alias Tarakan.Profiles
|
||||
|
||||
@sorts %{
|
||||
"reputation" => :reputation,
|
||||
"reviews" => :reviews,
|
||||
"findings" => :findings,
|
||||
"verdicts" => :verdicts
|
||||
}
|
||||
|
||||
@severities ~w(critical high medium low info)
|
||||
@windows %{"7" => 7, "30" => 30, "90" => 90, "all" => :all}
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Leaderboard")
|
||||
|> assign(
|
||||
:meta_description,
|
||||
"Who's putting work on the public record: reports, findings, checks."
|
||||
)
|
||||
|> assign(:canonical_path, ~p"/leaderboard")}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(params, _uri, socket) do
|
||||
sort = if is_map_key(@sorts, params["sort"]), do: params["sort"], else: "reputation"
|
||||
severity = if params["severity"] in @severities, do: params["severity"], else: nil
|
||||
window = if is_map_key(@windows, params["window"]), do: params["window"], else: "all"
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:sort, sort)
|
||||
|> assign(:severity, severity)
|
||||
|> assign(:window, window)
|
||||
|> load_entries()}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("sort", %{"by" => by}, socket) when is_map_key(@sorts, by) do
|
||||
{:noreply, patch_filters(socket, %{sort: by})}
|
||||
end
|
||||
|
||||
def handle_event("filter_severity", %{"severity" => severity}, socket) do
|
||||
severity = if severity in @severities, do: severity, else: nil
|
||||
{:noreply, patch_filters(socket, %{severity: severity})}
|
||||
end
|
||||
|
||||
def handle_event("filter_window", %{"window" => window}, socket)
|
||||
when is_map_key(@windows, window) do
|
||||
{:noreply, patch_filters(socket, %{window: window})}
|
||||
end
|
||||
|
||||
defp patch_filters(socket, updates) do
|
||||
params =
|
||||
%{
|
||||
sort: socket.assigns.sort,
|
||||
severity: socket.assigns.severity,
|
||||
window: socket.assigns.window
|
||||
}
|
||||
|> Map.merge(updates)
|
||||
# Keep the URL clean: defaults are implicit.
|
||||
|> Map.reject(fn {key, value} ->
|
||||
value in [nil, ""] or {key, value} in [sort: "reputation", window: "all"]
|
||||
end)
|
||||
|
||||
push_patch(socket, to: ~p"/leaderboard?#{params}")
|
||||
end
|
||||
|
||||
defp load_entries(socket) do
|
||||
opts = [
|
||||
severity: socket.assigns.severity,
|
||||
window: Map.fetch!(@windows, socket.assigns.window)
|
||||
]
|
||||
|
||||
entries = Leaderboard.top(Map.fetch!(@sorts, socket.assigns.sort), opts)
|
||||
|
||||
socket
|
||||
|> assign(:entries, entries)
|
||||
|> assign(:badges, Profiles.badges_for(Enum.map(entries, & &1.account.id)))
|
||||
end
|
||||
|
||||
@doc false
|
||||
def tier_label("reviewer"), do: "Reviewer"
|
||||
def tier_label("contributor"), do: "Contributor"
|
||||
def tier_label(_new), do: "New"
|
||||
|
||||
@doc false
|
||||
def badge_label(:first_blood), do: "First blood"
|
||||
def badge_label(:century), do: "Century"
|
||||
def badge_label(:eradicator), do: "Eradicator"
|
||||
def badge_label(:exorcist), do: "Exorcist"
|
||||
def badge_label(:sharpshooter), do: "Sharpshooter"
|
||||
def badge_label(badge), do: badge |> to_string() |> String.replace("_", " ")
|
||||
|
||||
@doc false
|
||||
def rank_style(1), do: "text-signal"
|
||||
def rank_style(2), do: "text-ink"
|
||||
def rank_style(3), do: "text-ink-muted"
|
||||
def rank_style(_), do: "text-ink-faint"
|
||||
end
|
||||
150
lib/tarakan_web/live/leaderboard_live.html.heex
Normal file
150
lib/tarakan_web/live/leaderboard_live.html.heex
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
|
||||
<Layouts.page>
|
||||
<.breadcrumbs id="leaderboard-breadcrumb">
|
||||
<:crumb navigate={~p"/"}>registry</:crumb>
|
||||
<:crumb>leaderboard</:crumb>
|
||||
</.breadcrumbs>
|
||||
|
||||
<div class="flex flex-col gap-3 border-b-2 border-strong pb-5 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h1 class="font-display text-3xl font-medium uppercase tracking-[0.02em] text-ink sm:text-4xl">
|
||||
Leaderboard
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="flex max-w-full flex-col gap-2 overflow-x-auto overscroll-x-contain [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
<div
|
||||
id="leaderboard-sort"
|
||||
class="flex border border-strong font-display text-[10px] uppercase tracking-[0.12em]"
|
||||
role="group"
|
||||
aria-label="Rank by"
|
||||
>
|
||||
<button
|
||||
:for={
|
||||
{value, label} <- [
|
||||
{"reputation", "Reputation"},
|
||||
{"reviews", "Reports"},
|
||||
{"findings", "Findings"},
|
||||
{"verdicts", "Checks"}
|
||||
]
|
||||
}
|
||||
type="button"
|
||||
phx-click="sort"
|
||||
phx-value-by={value}
|
||||
aria-pressed={to_string(@sort == value)}
|
||||
class={[
|
||||
"shrink-0 px-3 py-2 transition sm:py-1.5",
|
||||
@sort == value && "bg-btn text-btn-fg",
|
||||
@sort != value && "text-ink-faint hover:text-ink"
|
||||
]}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<form id="leaderboard-severity-form" phx-change="filter_severity">
|
||||
<label for="leaderboard-severity" class="sr-only">Filter by severity</label>
|
||||
<select
|
||||
id="leaderboard-severity"
|
||||
name="severity"
|
||||
class="h-full border border-strong bg-ground px-2 py-2 font-display text-[10px] uppercase tracking-[0.12em] text-ink-faint transition focus:border-phosphor focus:outline-none sm:py-1.5"
|
||||
>
|
||||
<option value="" selected={is_nil(@severity)}>All severities</option>
|
||||
<option
|
||||
:for={severity <- ~w(critical high medium low info)}
|
||||
value={severity}
|
||||
selected={@severity == severity}
|
||||
>
|
||||
{severity}
|
||||
</option>
|
||||
</select>
|
||||
</form>
|
||||
|
||||
<div
|
||||
id="leaderboard-window"
|
||||
class="flex border border-strong font-display text-[10px] uppercase tracking-[0.12em]"
|
||||
role="group"
|
||||
aria-label="Time window"
|
||||
>
|
||||
<button
|
||||
:for={
|
||||
{value, label} <- [
|
||||
{"7", "7d"},
|
||||
{"30", "30d"},
|
||||
{"90", "90d"},
|
||||
{"all", "All time"}
|
||||
]
|
||||
}
|
||||
type="button"
|
||||
phx-click="filter_window"
|
||||
phx-value-window={value}
|
||||
aria-pressed={to_string(@window == value)}
|
||||
class={[
|
||||
"shrink-0 px-3 py-2 transition sm:py-1.5",
|
||||
@window == value && "bg-btn text-btn-fg",
|
||||
@window != value && "text-ink-faint hover:text-ink"
|
||||
]}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p :if={@entries == []} id="leaderboard-empty" class="mt-8 font-mono text-sm text-ink-faint">
|
||||
No ranked contributors yet.
|
||||
</p>
|
||||
|
||||
<ol
|
||||
:if={@entries != []}
|
||||
id="leaderboard"
|
||||
class="mt-6 divide-y divide-rule border-t border-rule"
|
||||
>
|
||||
<li
|
||||
:for={{entry, index} <- Enum.with_index(@entries, 1)}
|
||||
id={"leaderboard-rank-#{index}"}
|
||||
class="flex items-center gap-4 py-3"
|
||||
>
|
||||
<span class={[
|
||||
"w-8 shrink-0 text-right font-display text-xl font-medium tabular-nums",
|
||||
rank_style(index)
|
||||
]}>
|
||||
{index}
|
||||
</span>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<.handle_link handle={entry.account.handle} class="font-mono text-sm text-ink" />
|
||||
<span class="border border-rule px-1.5 font-display text-[10px] uppercase tracking-[0.12em] text-ink-faint">
|
||||
{tier_label(entry.account.trust_tier)}
|
||||
</span>
|
||||
<span
|
||||
:for={badge <- Map.get(@badges, entry.account.id, [])}
|
||||
id={"leaderboard-rank-#{index}-badge-#{badge}"}
|
||||
class="border border-rule px-1.5 font-display text-[10px] uppercase tracking-[0.12em] text-quote"
|
||||
>
|
||||
{badge_label(badge)}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-0.5 font-mono text-[11px] text-ink-faint">
|
||||
{entry.stats.reviews} reports · {entry.stats.findings} findings · {entry.stats.verdicts} checks<span
|
||||
:if={entry.slashed_stakes > 0}
|
||||
class="text-signal"
|
||||
> · {entry.slashed_stakes} slashed</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="shrink-0 text-right">
|
||||
<p class="font-display text-2xl font-medium tabular-nums text-ink">
|
||||
{entry.reputation.total}
|
||||
</p>
|
||||
<p class="font-mono text-[11px] text-ink-faint">
|
||||
reputation
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</Layouts.page>
|
||||
</Layouts.app>
|
||||
265
lib/tarakan_web/live/model_analytics_live.ex
Normal file
265
lib/tarakan_web/live/model_analytics_live.ex
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
defmodule TarakanWeb.ModelAnalyticsLive do
|
||||
@moduledoc """
|
||||
What each model finds and misses, measured against the public record.
|
||||
|
||||
Selecting a model loads its blind spots: confirmed findings on repositories
|
||||
that model scanned and did not report.
|
||||
"""
|
||||
use TarakanWeb, :live_view
|
||||
|
||||
alias Tarakan.ModelAnalytics
|
||||
alias TarakanWeb.RepositoryPaths
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
scoreboard = ModelAnalytics.cached_model_scoreboard()
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Models")
|
||||
|> assign(
|
||||
:meta_description,
|
||||
"Detection accuracy and blind spots per model, measured against Tarakan's verified findings."
|
||||
)
|
||||
|> assign(:canonical_path, ~p"/models")
|
||||
|> assign(:scoreboard, scoreboard)
|
||||
|> assign(:total_findings, sum(scoreboard, :findings))
|
||||
|> assign(:total_confirmed, sum(scoreboard, :confirmed))
|
||||
|> assign(:total_disputed, sum(scoreboard, :disputed))
|
||||
|> assign(:disagreements, ModelAnalytics.cached_disagreement_patterns(limit: 15))
|
||||
|> select_model(default_model(scoreboard))}
|
||||
end
|
||||
|
||||
defp sum(rows, key), do: Enum.reduce(rows, 0, &(&2 + Map.fetch!(&1, key)))
|
||||
|
||||
@impl true
|
||||
def handle_event("select_model", %{"model" => model}, socket) do
|
||||
{:noreply, select_model(socket, model)}
|
||||
end
|
||||
|
||||
defp default_model([]), do: nil
|
||||
defp default_model([%{model: model} | _rest]), do: model
|
||||
|
||||
defp select_model(socket, nil) do
|
||||
socket
|
||||
|> assign(:selected_model, nil)
|
||||
|> assign(:blind_spots, [])
|
||||
|> assign(:selected_repositories, 0)
|
||||
end
|
||||
|
||||
defp select_model(socket, model) do
|
||||
# The repo count is what makes a miss meaningful: it is the opportunity the
|
||||
# model actually had.
|
||||
repositories =
|
||||
socket.assigns.scoreboard
|
||||
|> Enum.find(%{}, &(&1.model == model))
|
||||
|> Map.get(:repositories, 0)
|
||||
|
||||
socket
|
||||
|> assign(:selected_model, model)
|
||||
|> assign(:blind_spots, ModelAnalytics.cached_blind_spots(model, limit: 20))
|
||||
|> assign(:selected_repositories, repositories)
|
||||
end
|
||||
|
||||
defp precision_label(nil), do: "-"
|
||||
defp precision_label(precision), do: "#{precision}%"
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
|
||||
<Layouts.page>
|
||||
<.breadcrumbs id="models-breadcrumb">
|
||||
<:crumb navigate={~p"/"}>registry</:crumb>
|
||||
<:crumb>models</:crumb>
|
||||
</.breadcrumbs>
|
||||
|
||||
<div class="flex flex-wrap items-end justify-between gap-x-6 gap-y-3 border-b-2 border-strong pb-5">
|
||||
<h1 class="font-display text-3xl font-medium uppercase tracking-[0.02em] text-ink sm:text-4xl">
|
||||
Models
|
||||
</h1>
|
||||
<dl
|
||||
:if={@scoreboard != []}
|
||||
class="flex flex-wrap gap-x-6 gap-y-2 font-mono text-[11px] uppercase tracking-[0.12em]"
|
||||
>
|
||||
<div>
|
||||
<dt class="text-ink-faint">Ranked</dt>
|
||||
<dd class="mt-0.5 font-display text-lg tabular-nums text-ink">
|
||||
{length(@scoreboard)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-ink-faint">Findings</dt>
|
||||
<dd class="mt-0.5 font-display text-lg tabular-nums text-ink">{@total_findings}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-ink-faint">Confirmed</dt>
|
||||
<dd class="mt-0.5 font-display text-lg tabular-nums text-phosphor">
|
||||
{@total_confirmed}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-ink-faint">Disputed</dt>
|
||||
<dd class="mt-0.5 font-display text-lg tabular-nums text-ink-muted">
|
||||
{@total_disputed}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<p
|
||||
:if={@scoreboard == []}
|
||||
id="models-empty"
|
||||
class="mt-8 font-mono text-xs text-ink-faint"
|
||||
>
|
||||
No model has reported enough findings yet.
|
||||
</p>
|
||||
|
||||
<section :if={@scoreboard != []} id="model-scoreboard" class="mt-8">
|
||||
<div class="overflow-x-auto border-2 border-strong bg-ground">
|
||||
<table class="w-full min-w-[40rem] border-collapse text-left">
|
||||
<thead>
|
||||
<tr class="border-b border-rule font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint">
|
||||
<th class="px-3 py-2 font-normal sm:px-4">Model</th>
|
||||
<th class="w-20 px-2 py-2 text-right font-normal">Findings</th>
|
||||
<th class="w-20 px-2 py-2 text-right font-normal">Confirmed</th>
|
||||
<th class="w-20 px-2 py-2 text-right font-normal">Disputed</th>
|
||||
<th class="w-24 px-2 py-2 text-right font-normal">Precision</th>
|
||||
<th class="hidden w-20 px-3 py-2 text-right font-normal sm:table-cell sm:px-4">
|
||||
Repos
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-rule">
|
||||
<tr
|
||||
:for={row <- @scoreboard}
|
||||
id={"model-row-#{row.model}"}
|
||||
phx-click="select_model"
|
||||
phx-value-model={row.model}
|
||||
aria-selected={to_string(@selected_model == row.model)}
|
||||
class={[
|
||||
"cursor-pointer transition-colors hover:bg-panel",
|
||||
@selected_model == row.model && "bg-panel"
|
||||
]}
|
||||
>
|
||||
<td class="min-w-0 px-3 py-2.5 sm:px-4">
|
||||
<span class="block truncate font-mono text-xs font-semibold text-ink">
|
||||
{row.model}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-2 py-2.5 text-right font-mono text-xs tabular-nums text-ink">
|
||||
{row.findings}
|
||||
</td>
|
||||
<td class="px-2 py-2.5 text-right font-mono text-xs tabular-nums text-phosphor">
|
||||
{row.confirmed}
|
||||
</td>
|
||||
<td class="px-2 py-2.5 text-right font-mono text-xs tabular-nums text-ink-muted">
|
||||
{row.disputed}
|
||||
</td>
|
||||
<td class="px-2 py-2.5 text-right font-mono text-xs tabular-nums text-ink">
|
||||
{precision_label(row.precision)}
|
||||
</td>
|
||||
<td class="hidden px-3 py-2.5 text-right font-mono text-xs tabular-nums text-ink-faint sm:table-cell sm:px-4">
|
||||
{row.repositories}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section :if={@selected_model} id="model-blind-spots" class="mt-10">
|
||||
<div class="mb-3 flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
|
||||
<h2 class="font-display text-lg uppercase tracking-[0.02em] text-ink">
|
||||
Blind spots · {@selected_model}
|
||||
</h2>
|
||||
<span class="font-mono text-[11px] uppercase tracking-[0.12em] text-ink-faint">
|
||||
{length(@blind_spots)} missed · {@selected_repositories} repos scanned
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p :if={@blind_spots == []} class="font-mono text-xs text-ink-faint">
|
||||
None recorded.
|
||||
</p>
|
||||
|
||||
<ul
|
||||
:if={@blind_spots != []}
|
||||
class="divide-y divide-rule border-2 border-strong bg-ground"
|
||||
>
|
||||
<li
|
||||
:for={finding <- @blind_spots}
|
||||
id={"blind-spot-#{finding.id}"}
|
||||
class="flex flex-wrap items-baseline gap-x-3 gap-y-1 px-4 py-3"
|
||||
>
|
||||
<.link
|
||||
navigate={~p"/findings/#{finding.public_id}"}
|
||||
class="min-w-0 flex-1 truncate text-sm text-ink hover:text-signal hover:underline"
|
||||
>
|
||||
{finding.title}
|
||||
</.link>
|
||||
<.link
|
||||
navigate={RepositoryPaths.repository_path(finding.repository)}
|
||||
class="shrink-0 font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint hover:text-signal"
|
||||
>
|
||||
{finding.repository.owner}/{finding.repository.name}
|
||||
</.link>
|
||||
<span class="shrink-0 font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint">
|
||||
{finding.detections_count} detections
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section :if={@disagreements != []} id="model-disagreements" class="mt-10">
|
||||
<div class="mb-3 flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
|
||||
<h2 class="font-display text-lg uppercase tracking-[0.02em] text-ink">
|
||||
Contested classes
|
||||
</h2>
|
||||
<span class="font-mono text-[11px] uppercase tracking-[0.12em] text-ink-faint">
|
||||
{length(@disagreements)} reported by 2+ models
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto border-2 border-strong bg-ground">
|
||||
<table class="w-full min-w-[36rem] border-collapse text-left">
|
||||
<thead>
|
||||
<tr class="border-b border-rule font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint">
|
||||
<th class="px-3 py-2 font-normal sm:px-4">Class</th>
|
||||
<th class="w-20 px-2 py-2 text-right font-normal">Models</th>
|
||||
<th class="w-24 px-2 py-2 text-right font-normal">Confirmed</th>
|
||||
<th class="w-24 px-3 py-2 text-right font-normal sm:px-4">Disputed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-rule">
|
||||
<tr
|
||||
:for={row <- @disagreements}
|
||||
id={"disagreement-#{row.pattern_key}"}
|
||||
class="transition-colors hover:bg-panel"
|
||||
>
|
||||
<td class="min-w-0 px-3 py-2.5 sm:px-4">
|
||||
<.link
|
||||
navigate={~p"/infestations/#{row.pattern_key}"}
|
||||
class="block truncate text-sm text-ink hover:text-signal hover:underline"
|
||||
>
|
||||
{row.title}
|
||||
</.link>
|
||||
</td>
|
||||
<td class="px-2 py-2.5 text-right font-mono text-xs tabular-nums text-ink">
|
||||
{row.models}
|
||||
</td>
|
||||
<td class="px-2 py-2.5 text-right font-mono text-xs tabular-nums text-phosphor">
|
||||
{row.confirmed}
|
||||
</td>
|
||||
<td class="px-3 py-2.5 text-right font-mono text-xs tabular-nums text-ink-muted sm:px-4">
|
||||
{row.disputed}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</Layouts.page>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
end
|
||||
247
lib/tarakan_web/live/moderation_case_live/show.ex
Normal file
247
lib/tarakan_web/live/moderation_case_live/show.ex
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
defmodule TarakanWeb.ModerationCaseLive.Show do
|
||||
use TarakanWeb, :live_view
|
||||
|
||||
alias Tarakan.Accounts
|
||||
alias Tarakan.Moderation
|
||||
alias Tarakan.Policy
|
||||
|
||||
@impl true
|
||||
def mount(%{"id" => id}, _session, socket) do
|
||||
case Moderation.get_case(socket.assigns.current_scope, id) do
|
||||
{:ok, case_record} ->
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Moderation case ##{case_record.id}")
|
||||
|> assign(:appeal_form, appeal_form())
|
||||
|> assign(:resolution_form, resolution_form())
|
||||
|> assign_case(case_record)}
|
||||
|
||||
{:error, :not_found} ->
|
||||
raise Ecto.NoResultsError, queryable: Tarakan.Moderation.Case
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("assign", _params, socket) do
|
||||
case Moderation.assign(
|
||||
socket.assigns.current_scope,
|
||||
socket.assigns.case_record
|
||||
) do
|
||||
{:ok, case_record} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign_case(case_record)
|
||||
|> put_flash(:info, "Case assigned for independent review.")}
|
||||
|
||||
error ->
|
||||
{:noreply, put_flash(socket, :error, error_message(error))}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event(
|
||||
"resolve",
|
||||
%{"disposition" => disposition, "resolution" => %{"reason" => reason}},
|
||||
socket
|
||||
)
|
||||
when disposition in ["resolved", "dismissed"] do
|
||||
with_recent_auth(socket, fn ->
|
||||
case Moderation.resolve(
|
||||
socket.assigns.current_scope,
|
||||
socket.assigns.case_record,
|
||||
disposition,
|
||||
reason
|
||||
) do
|
||||
{:ok, case_record} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:resolution_form, resolution_form())
|
||||
|> assign_case(case_record)
|
||||
|> put_flash(:info, resolution_message(disposition))}
|
||||
|
||||
error ->
|
||||
{:noreply, put_flash(socket, :error, error_message(error))}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
def handle_event("resolve", _params, socket) do
|
||||
{:noreply, put_flash(socket, :error, "Choose a valid moderation outcome.")}
|
||||
end
|
||||
|
||||
def handle_event("appeal", %{"appeal" => attrs}, socket) do
|
||||
case Moderation.appeal(
|
||||
socket.assigns.current_scope,
|
||||
socket.assigns.case_record,
|
||||
attrs
|
||||
) do
|
||||
{:ok, _appeal} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> refresh_case()
|
||||
|> put_flash(:info, "Appeal submitted for independent review.")}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign(socket, :appeal_form, to_form(changeset, as: :appeal))}
|
||||
|
||||
error ->
|
||||
{:noreply, put_flash(socket, :error, error_message(error))}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("appeal", _params, socket) do
|
||||
{:noreply, put_flash(socket, :error, "The appeal is incomplete.")}
|
||||
end
|
||||
|
||||
def handle_event(
|
||||
"decide_appeal",
|
||||
%{
|
||||
"decision" => decision,
|
||||
"appeal_decision" => %{"appeal_id" => appeal_id, "reason" => reason}
|
||||
},
|
||||
socket
|
||||
)
|
||||
when decision in ["upheld", "denied"] do
|
||||
with_recent_auth(socket, fn ->
|
||||
with {:ok, appeal_id} <- normalize_id(appeal_id),
|
||||
appeal when not is_nil(appeal) <-
|
||||
Enum.find(socket.assigns.case_record.appeals, &(&1.id == appeal_id)) do
|
||||
case Moderation.decide_appeal(
|
||||
socket.assigns.current_scope,
|
||||
appeal,
|
||||
decision,
|
||||
reason
|
||||
) do
|
||||
{:ok, _appeal} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> refresh_case()
|
||||
|> put_flash(:info, appeal_decision_message(decision))}
|
||||
|
||||
error ->
|
||||
{:noreply, put_flash(socket, :error, error_message(error))}
|
||||
end
|
||||
else
|
||||
_other -> {:noreply, put_flash(socket, :error, "That appeal is no longer available.")}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
def handle_event("decide_appeal", _params, socket) do
|
||||
{:noreply, put_flash(socket, :error, "Choose a valid appeal decision.")}
|
||||
end
|
||||
|
||||
defp refresh_case(socket) do
|
||||
case Moderation.get_case(socket.assigns.current_scope, socket.assigns.case_record.id) do
|
||||
{:ok, case_record} -> assign_case(socket, case_record)
|
||||
{:error, :not_found} -> push_navigate(socket, to: ~p"/")
|
||||
end
|
||||
end
|
||||
|
||||
defp assign_case(socket, case_record) do
|
||||
socket
|
||||
|> assign(:case_record, case_record)
|
||||
|> assign(:moderator?, moderator?(socket.assigns.current_scope))
|
||||
|> assign(
|
||||
:appeal_decision_forms,
|
||||
Map.new(case_record.appeals, &{&1.id, appeal_decision_form(&1)})
|
||||
)
|
||||
end
|
||||
|
||||
defp moderator?(scope), do: Policy.moderator?(scope) and scope.account_state == "active"
|
||||
|
||||
defp can_assign?(case_record, scope) do
|
||||
moderator?(scope) and independent_from_case?(case_record, scope) and
|
||||
(case_record.status == "open" or
|
||||
(case_record.status == "in_review" and scope.platform_role == "admin"))
|
||||
end
|
||||
|
||||
defp can_resolve?(case_record, scope) do
|
||||
moderator?(scope) and case_record.status == "in_review" and
|
||||
case_record.assigned_to_id == scope.account_id
|
||||
end
|
||||
|
||||
defp can_appeal?(case_record, scope) do
|
||||
case_record.status == "resolved" and case_record.appeals == [] and
|
||||
(case_record.subject_owner_id == scope.account_id or
|
||||
Policy.repository_steward?(scope, case_record))
|
||||
end
|
||||
|
||||
defp can_decide_appeal?(case_record, appeal, scope) do
|
||||
moderator?(scope) and appeal.status == "open" and
|
||||
scope.account_id not in [
|
||||
case_record.reporter_id,
|
||||
case_record.subject_owner_id,
|
||||
case_record.resolved_by_id,
|
||||
appeal.appellant_id
|
||||
]
|
||||
end
|
||||
|
||||
defp independent_from_case?(case_record, scope) do
|
||||
scope.account_id not in [case_record.reporter_id, case_record.subject_owner_id]
|
||||
end
|
||||
|
||||
defp appeal_form, do: to_form(%{"reason" => ""}, as: :appeal)
|
||||
defp resolution_form, do: to_form(%{"reason" => ""}, as: :resolution)
|
||||
|
||||
defp with_recent_auth(socket, fun) do
|
||||
if Accounts.sudo_mode?(socket.assigns.current_scope.account) do
|
||||
fun.()
|
||||
else
|
||||
return_to = ~p"/moderation/cases/#{socket.assigns.case_record.id}"
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(
|
||||
:error,
|
||||
"Confirm it's you with a magic link before deciding moderation outcomes (sign-in older than 8 hours)."
|
||||
)
|
||||
|> push_navigate(to: TarakanWeb.AccountAuth.reauth_path(return_to))}
|
||||
end
|
||||
end
|
||||
|
||||
defp appeal_decision_form(appeal) do
|
||||
to_form(%{"appeal_id" => appeal.id, "reason" => ""}, as: :appeal_decision)
|
||||
end
|
||||
|
||||
defp normalize_id(id) when is_integer(id) and id > 0, do: {:ok, id}
|
||||
|
||||
defp normalize_id(id) when is_binary(id) do
|
||||
case Integer.parse(id) do
|
||||
{parsed, ""} when parsed > 0 -> {:ok, parsed}
|
||||
_other -> {:error, :invalid_id}
|
||||
end
|
||||
end
|
||||
|
||||
defp normalize_id(_id), do: {:error, :invalid_id}
|
||||
|
||||
defp status_label("in_review"), do: "In review"
|
||||
defp status_label(status), do: String.capitalize(status)
|
||||
|
||||
defp subject_label("review_task"), do: "Job"
|
||||
|
||||
defp subject_label(subject_type),
|
||||
do: subject_type |> String.replace("_", " ") |> String.capitalize()
|
||||
|
||||
defp reason_label(reason), do: reason |> String.replace("_", " ") |> String.capitalize()
|
||||
|
||||
defp resolution_message("resolved"), do: "Case resolved."
|
||||
defp resolution_message("dismissed"), do: "Case dismissed."
|
||||
|
||||
defp appeal_decision_message("upheld"), do: "Appeal upheld and case overturned."
|
||||
defp appeal_decision_message("denied"), do: "Appeal denied."
|
||||
|
||||
defp error_message({:error, :conflict_of_interest}),
|
||||
do: "An independent moderator must handle this action."
|
||||
|
||||
defp error_message({:error, :not_assigned}), do: "This case is assigned to another moderator."
|
||||
defp error_message({:error, :not_appealable}), do: "This case is not eligible for appeal."
|
||||
defp error_message({:error, :already_decided}), do: "That appeal has already been decided."
|
||||
defp error_message({:error, :invalid_reason}), do: "Provide a reason of at least 10 characters."
|
||||
|
||||
defp error_message({:error, :invalid_transition}),
|
||||
do: "That case transition is no longer valid."
|
||||
|
||||
defp error_message({:error, :unauthorized}), do: "You are not authorized for that action."
|
||||
defp error_message({:error, :not_found}), do: "The case is no longer available."
|
||||
defp error_message({:error, _reason}), do: "The moderation action could not be completed."
|
||||
end
|
||||
235
lib/tarakan_web/live/moderation_case_live/show.html.heex
Normal file
235
lib/tarakan_web/live/moderation_case_live/show.html.heex
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
|
||||
<Layouts.page>
|
||||
<.breadcrumbs>
|
||||
<:crumb navigate={~p"/"}>registry</:crumb>
|
||||
<:crumb navigate={if @moderator?, do: ~p"/moderation/queue"}>moderation</:crumb>
|
||||
<:crumb>case/{@case_record.id}</:crumb>
|
||||
</.breadcrumbs>
|
||||
|
||||
<main id="moderation-case" class="border-2 border-strong">
|
||||
<header class="border-b-2 border-strong bg-panel px-5 py-6 sm:px-8 sm:py-8">
|
||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||
<h1 class="font-display text-3xl uppercase tracking-[0.02em] text-ink sm:text-4xl">
|
||||
Case #{@case_record.id}
|
||||
</h1>
|
||||
<span
|
||||
id="moderation-case-status"
|
||||
class="border border-rule px-3 py-1 font-mono text-[11px] uppercase tracking-[0.12em] text-ink-muted"
|
||||
>
|
||||
{status_label(@case_record.status)}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-3 text-sm text-ink-muted">
|
||||
{subject_label(@case_record.subject_type)} #{@case_record.subject_id}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section id="moderation-case-report" class="px-5 py-6 sm:px-8 sm:py-8">
|
||||
<dl class="grid gap-6 sm:grid-cols-[10rem_minmax(0,1fr)]">
|
||||
<div>
|
||||
<dt class="font-mono text-[11px] uppercase tracking-[0.12em] text-ink-faint">
|
||||
Reason
|
||||
</dt>
|
||||
<dd class="mt-2 text-sm text-ink">{reason_label(@case_record.reason)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="font-mono text-[11px] uppercase tracking-[0.12em] text-ink-faint">
|
||||
Report
|
||||
</dt>
|
||||
<dd class="mt-2 whitespace-pre-line text-sm leading-6 text-ink-muted" phx-no-format>{@case_record.description}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div
|
||||
:if={@case_record.resolution}
|
||||
id="moderation-case-resolution"
|
||||
class="mt-7 border-t border-rule pt-6"
|
||||
>
|
||||
<h2 class="font-mono text-[11px] uppercase tracking-[0.12em] text-ink-faint">
|
||||
Decision
|
||||
</h2>
|
||||
<p class="mt-2 whitespace-pre-line text-sm leading-6 text-ink-muted" phx-no-format>{@case_record.resolution}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
:if={can_assign?(@case_record, @current_scope)}
|
||||
id="moderation-case-assignment"
|
||||
class="border-t border-rule px-5 py-6 sm:px-8"
|
||||
>
|
||||
<h2 class="font-display text-xl uppercase tracking-[0.02em] text-ink">
|
||||
Independent review
|
||||
</h2>
|
||||
<button
|
||||
id="assign-moderation-case"
|
||||
phx-click="assign"
|
||||
class="clip-notch mt-5 bg-btn px-5 py-2.5 font-display text-xs uppercase tracking-[0.12em] text-btn-fg transition hover:opacity-90 phx-click-loading:opacity-60"
|
||||
>
|
||||
Assign to me
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section
|
||||
:if={can_resolve?(@case_record, @current_scope)}
|
||||
id="moderation-case-decision"
|
||||
class="border-t border-rule px-5 py-6 sm:px-8"
|
||||
>
|
||||
<h2 class="font-display text-xl uppercase tracking-[0.02em] text-ink">Decide case</h2>
|
||||
<.form
|
||||
for={@resolution_form}
|
||||
id="moderation-case-decision-form"
|
||||
phx-submit="resolve"
|
||||
class="mt-5"
|
||||
>
|
||||
<.input
|
||||
field={@resolution_form[:reason]}
|
||||
type="textarea"
|
||||
label="Decision reason"
|
||||
minlength="10"
|
||||
maxlength="2000"
|
||||
rows="5"
|
||||
/>
|
||||
<div class="mt-4 flex flex-wrap gap-3">
|
||||
<button
|
||||
id="resolve-moderation-case"
|
||||
type="submit"
|
||||
name="disposition"
|
||||
value="resolved"
|
||||
class="clip-notch bg-btn px-4 py-2 font-display text-xs uppercase tracking-[0.12em] text-btn-fg transition hover:opacity-90"
|
||||
>
|
||||
Resolve report
|
||||
</button>
|
||||
<button
|
||||
id="dismiss-moderation-case"
|
||||
type="submit"
|
||||
name="disposition"
|
||||
value="dismissed"
|
||||
class="border border-rule px-4 py-2 font-display text-xs uppercase tracking-[0.12em] text-ink transition hover:border-strong"
|
||||
>
|
||||
Dismiss report
|
||||
</button>
|
||||
</div>
|
||||
</.form>
|
||||
</section>
|
||||
|
||||
<section
|
||||
:if={can_appeal?(@case_record, @current_scope)}
|
||||
id="moderation-case-appeal"
|
||||
class="border-t border-rule px-5 py-6 sm:px-8"
|
||||
>
|
||||
<h2 class="font-display text-xl uppercase tracking-[0.02em] text-ink">Appeal decision</h2>
|
||||
<.form for={@appeal_form} id="moderation-appeal-form" phx-submit="appeal" class="mt-5">
|
||||
<.input
|
||||
field={@appeal_form[:reason]}
|
||||
type="textarea"
|
||||
label="Grounds for appeal"
|
||||
minlength="20"
|
||||
maxlength="5000"
|
||||
rows="6"
|
||||
/>
|
||||
<button
|
||||
id="submit-moderation-appeal"
|
||||
type="submit"
|
||||
class="clip-notch mt-4 bg-btn px-5 py-2.5 font-display text-xs uppercase tracking-[0.12em] text-btn-fg transition hover:opacity-90 phx-submit-loading:opacity-60"
|
||||
>
|
||||
Submit appeal
|
||||
</button>
|
||||
</.form>
|
||||
</section>
|
||||
|
||||
<section
|
||||
:if={@case_record.appeals != []}
|
||||
id="moderation-case-appeals"
|
||||
class="border-t border-rule px-5 py-6 sm:px-8"
|
||||
>
|
||||
<h2 class="font-display text-xl uppercase tracking-[0.02em] text-ink">Appeals</h2>
|
||||
<div class="mt-5 grid gap-5">
|
||||
<article
|
||||
:for={appeal <- @case_record.appeals}
|
||||
id={"moderation-appeal-#{appeal.id}"}
|
||||
class="border border-rule p-5"
|
||||
>
|
||||
<% appeal_form = Map.fetch!(@appeal_decision_forms, appeal.id) %>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<span class="font-mono text-xs text-ink-faint">Appeal #{appeal.id}</span>
|
||||
<span class="font-mono text-[11px] uppercase tracking-[0.12em] text-ink-muted">
|
||||
{status_label(appeal.status)}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-4 whitespace-pre-line text-sm leading-6 text-ink-muted" phx-no-format>{appeal.reason}</p>
|
||||
<div :if={appeal.decision_reason} class="mt-5 border-t border-rule pt-4">
|
||||
<h3 class="font-mono text-[11px] uppercase tracking-[0.12em] text-ink-faint">
|
||||
Appeal decision
|
||||
</h3>
|
||||
<p class="mt-2 whitespace-pre-line text-sm leading-6 text-ink-muted" phx-no-format>{appeal.decision_reason}</p>
|
||||
</div>
|
||||
|
||||
<.form
|
||||
:if={can_decide_appeal?(@case_record, appeal, @current_scope)}
|
||||
for={appeal_form}
|
||||
id={"moderation-appeal-decision-form-#{appeal.id}"}
|
||||
phx-submit="decide_appeal"
|
||||
class="mt-5 border-t border-rule pt-5"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name={appeal_form[:appeal_id].name}
|
||||
value={appeal.id}
|
||||
/>
|
||||
<.input
|
||||
field={appeal_form[:reason]}
|
||||
id={"moderation-appeal-decision-reason-#{appeal.id}"}
|
||||
type="textarea"
|
||||
label="Independent decision reason"
|
||||
minlength="10"
|
||||
maxlength="2000"
|
||||
rows="4"
|
||||
/>
|
||||
<div class="mt-4 flex flex-wrap gap-3">
|
||||
<button
|
||||
id={"uphold-moderation-appeal-#{appeal.id}"}
|
||||
type="submit"
|
||||
name="decision"
|
||||
value="upheld"
|
||||
class="clip-notch bg-btn px-4 py-2 font-display text-xs uppercase tracking-[0.12em] text-btn-fg transition hover:opacity-90"
|
||||
>
|
||||
Uphold appeal
|
||||
</button>
|
||||
<button
|
||||
id={"deny-moderation-appeal-#{appeal.id}"}
|
||||
type="submit"
|
||||
name="decision"
|
||||
value="denied"
|
||||
class="border border-rule px-4 py-2 font-display text-xs uppercase tracking-[0.12em] text-ink transition hover:border-strong"
|
||||
>
|
||||
Deny appeal
|
||||
</button>
|
||||
</div>
|
||||
</.form>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
:if={
|
||||
@moderator? && Ecto.assoc_loaded?(@case_record.actions) && @case_record.actions != []
|
||||
}
|
||||
id="moderation-case-actions"
|
||||
class="border-t border-rule px-5 py-6 sm:px-8"
|
||||
>
|
||||
<h2 class="font-display text-xl uppercase tracking-[0.02em] text-ink">Case history</h2>
|
||||
<ol class="mt-5 grid gap-4">
|
||||
<li :for={action <- @case_record.actions} id={"moderation-action-#{action.id}"}>
|
||||
<p class="font-mono text-[11px] uppercase tracking-[0.12em] text-ink-muted">
|
||||
{reason_label(action.action)} · {Calendar.strftime(
|
||||
action.inserted_at,
|
||||
"%Y-%m-%d %H:%M UTC"
|
||||
)}
|
||||
</p>
|
||||
<p class="mt-1 text-sm leading-6 text-ink-muted">{action.reason}</p>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
</main>
|
||||
</Layouts.page>
|
||||
</Layouts.app>
|
||||
79
lib/tarakan_web/live/moderation_queue_live/index.ex
Normal file
79
lib/tarakan_web/live/moderation_queue_live/index.ex
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
defmodule TarakanWeb.ModerationQueueLive.Index do
|
||||
use TarakanWeb, :live_view
|
||||
|
||||
alias Tarakan.Moderation
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
case Moderation.list_open(socket.assigns.current_scope) do
|
||||
{:ok, cases} ->
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Moderation queue")
|
||||
|> assign(:case_count, length(cases))
|
||||
|> stream(:cases, cases)}
|
||||
|
||||
{:error, :unauthorized} ->
|
||||
{:ok,
|
||||
socket
|
||||
|> put_flash(:error, "Moderator access is required.")
|
||||
|> redirect(to: ~p"/")}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("assign", %{"id" => id}, socket) do
|
||||
with {:ok, case_record} <- Moderation.get_case(socket.assigns.current_scope, id),
|
||||
{:ok, _assigned} <- Moderation.assign(socket.assigns.current_scope, case_record) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> refresh_queue()
|
||||
|> put_flash(:info, "Case assigned for independent review.")}
|
||||
else
|
||||
error -> {:noreply, put_flash(socket, :error, error_message(error))}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("assign", _params, socket) do
|
||||
{:noreply, put_flash(socket, :error, "That case is no longer available.")}
|
||||
end
|
||||
|
||||
defp refresh_queue(socket) do
|
||||
case Moderation.list_open(socket.assigns.current_scope) do
|
||||
{:ok, cases} ->
|
||||
socket
|
||||
|> assign(:case_count, length(cases))
|
||||
|> stream(:cases, cases, reset: true)
|
||||
|
||||
{:error, :unauthorized} ->
|
||||
socket
|
||||
|> put_flash(:error, "Moderator access is required.")
|
||||
|> push_navigate(to: ~p"/")
|
||||
end
|
||||
end
|
||||
|
||||
defp can_assign?(case_record, scope) do
|
||||
independent = scope.account_id not in [case_record.reporter_id, case_record.subject_owner_id]
|
||||
|
||||
independent and
|
||||
(case_record.status == "open" or
|
||||
(case_record.status == "in_review" and scope.platform_role == "admin"))
|
||||
end
|
||||
|
||||
defp status_label("in_review"), do: "In review"
|
||||
defp status_label(status), do: String.capitalize(status)
|
||||
|
||||
defp subject_label("review_task"), do: "Job"
|
||||
|
||||
defp subject_label(subject_type),
|
||||
do: subject_type |> String.replace("_", " ") |> String.capitalize()
|
||||
|
||||
defp reason_label(reason), do: reason |> String.replace("_", " ") |> String.capitalize()
|
||||
|
||||
defp error_message({:error, :conflict_of_interest}),
|
||||
do: "An independent moderator must handle that case."
|
||||
|
||||
defp error_message({:error, :unauthorized}), do: "Moderator access is required."
|
||||
defp error_message({:error, :not_found}), do: "That case is no longer available."
|
||||
defp error_message({:error, _reason}), do: "The case could not be assigned."
|
||||
end
|
||||
75
lib/tarakan_web/live/moderation_queue_live/index.html.heex
Normal file
75
lib/tarakan_web/live/moderation_queue_live/index.html.heex
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
|
||||
<Layouts.page>
|
||||
<.breadcrumbs>
|
||||
<:crumb navigate={~p"/"}>registry</:crumb>
|
||||
<:crumb>moderation</:crumb>
|
||||
</.breadcrumbs>
|
||||
|
||||
<main id="moderation-queue">
|
||||
<header class="flex flex-wrap items-end justify-between gap-4 border-b-2 border-strong pb-6">
|
||||
<div>
|
||||
<h1 class="font-display text-3xl uppercase tracking-[0.02em] text-ink sm:text-4xl">
|
||||
Moderation queue
|
||||
</h1>
|
||||
<p class="mt-3 max-w-2xl text-sm leading-6 text-ink-muted">
|
||||
Restricted reports awaiting independent review.
|
||||
</p>
|
||||
</div>
|
||||
<span id="moderation-case-count" class="font-mono text-xs text-ink-faint">
|
||||
{@case_count} open
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div id="moderation-cases" phx-update="stream" class="mt-6 grid gap-4">
|
||||
<div
|
||||
id="moderation-cases-empty"
|
||||
class="hidden border border-rule px-5 py-10 text-center text-sm text-ink-muted only:block"
|
||||
>
|
||||
No cases are waiting for review.
|
||||
</div>
|
||||
|
||||
<article
|
||||
:for={{dom_id, case_record} <- @streams.cases}
|
||||
id={dom_id}
|
||||
class="border-2 border-strong bg-panel px-5 py-5 sm:px-6"
|
||||
>
|
||||
<div class="flex flex-col gap-5 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-3 font-mono text-[11px] uppercase tracking-[0.12em] text-ink-faint">
|
||||
<span>Case #{case_record.id}</span>
|
||||
<span>{status_label(case_record.status)}</span>
|
||||
<span>{subject_label(case_record.subject_type)} #{case_record.subject_id}</span>
|
||||
</div>
|
||||
<h2 class="mt-3 font-display text-lg uppercase tracking-[0.02em] text-ink">
|
||||
{reason_label(case_record.reason)}
|
||||
</h2>
|
||||
<p
|
||||
class="mt-2 line-clamp-3 max-w-3xl whitespace-pre-line text-sm leading-6 text-ink-muted"
|
||||
phx-no-format
|
||||
>{case_record.description}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 flex-wrap items-center gap-4">
|
||||
<button
|
||||
:if={can_assign?(case_record, @current_scope)}
|
||||
id={"assign-moderation-case-#{case_record.id}"}
|
||||
phx-click="assign"
|
||||
phx-value-id={case_record.id}
|
||||
class="clip-notch bg-btn px-4 py-2 font-display text-xs uppercase tracking-[0.12em] text-btn-fg transition hover:opacity-90 phx-click-loading:opacity-60"
|
||||
>
|
||||
Assign
|
||||
</button>
|
||||
<.link
|
||||
id={"view-moderation-case-#{case_record.id}"}
|
||||
navigate={~p"/moderation/cases/#{case_record.id}"}
|
||||
class="font-mono text-xs text-signal transition hover:underline"
|
||||
>
|
||||
View case
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</main>
|
||||
</Layouts.page>
|
||||
</Layouts.app>
|
||||
89
lib/tarakan_web/live/moderation_report_live/new.ex
Normal file
89
lib/tarakan_web/live/moderation_report_live/new.ex
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
defmodule TarakanWeb.ModerationReportLive.New do
|
||||
use TarakanWeb, :live_view
|
||||
|
||||
alias Tarakan.Moderation
|
||||
|
||||
@subject_options [
|
||||
{"Repository", "repository"},
|
||||
{"Account", "account"},
|
||||
{"Scan", "scan"},
|
||||
{"Finding", "finding"},
|
||||
{"Job", "review_task"},
|
||||
{"Contribution", "contribution"}
|
||||
]
|
||||
|
||||
@reason_options [
|
||||
{"Spam", "spam"},
|
||||
{"Unsafe disclosure", "unsafe_disclosure"},
|
||||
{"Harassment", "harassment"},
|
||||
{"Plagiarism", "plagiarism"},
|
||||
{"Malicious instructions", "malicious_instructions"},
|
||||
{"Fabricated evidence", "fabricated_evidence"},
|
||||
{"Secrets or personal data", "secrets_or_pii"},
|
||||
{"Other", "other"}
|
||||
]
|
||||
|
||||
@impl true
|
||||
def mount(params, _session, socket) do
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Report content")
|
||||
|> assign(:subject_options, @subject_options)
|
||||
|> assign(:reason_options, @reason_options)
|
||||
|> assign(:form, report_form(initial_params(params)))}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("submit", %{"report" => attrs}, socket) do
|
||||
case Moderation.report(socket.assigns.current_scope, attrs) do
|
||||
{:ok, case_record} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Report submitted for restricted moderator review.")
|
||||
|> push_navigate(to: ~p"/moderation/cases/#{case_record.id}")}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign(socket, :form, to_form(changeset, as: :report))}
|
||||
|
||||
error ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:form, report_form(attrs))
|
||||
|> put_flash(:error, error_message(error))}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("submit", _params, socket) do
|
||||
{:noreply, put_flash(socket, :error, "Complete the report before submitting it.")}
|
||||
end
|
||||
|
||||
defp initial_params(params) do
|
||||
%{
|
||||
"subject_type" => query_value(params, "subject_type", "repository"),
|
||||
"subject_id" => query_value(params, "subject_id", ""),
|
||||
"reason" => query_value(params, "reason", "unsafe_disclosure"),
|
||||
"description" => ""
|
||||
}
|
||||
end
|
||||
|
||||
defp query_value(params, key, default) do
|
||||
case Map.get(params, key) do
|
||||
value when is_binary(value) -> value
|
||||
_other -> default
|
||||
end
|
||||
end
|
||||
|
||||
defp report_form(attrs), do: to_form(attrs, as: :report)
|
||||
|
||||
defp error_message({:error, :rate_limited}),
|
||||
do: "You have reached the daily report limit. Try again later."
|
||||
|
||||
defp error_message({:error, :subject_not_found}),
|
||||
do: "That content could not be found or is not visible to your account."
|
||||
|
||||
defp error_message({:error, :unauthorized}),
|
||||
do: "Your account is not authorized to submit this report."
|
||||
|
||||
defp error_message({:error, :invalid_report}), do: "The report is incomplete."
|
||||
defp error_message({:error, _reason}), do: "The report could not be submitted."
|
||||
end
|
||||
70
lib/tarakan_web/live/moderation_report_live/new.html.heex
Normal file
70
lib/tarakan_web/live/moderation_report_live/new.html.heex
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
|
||||
<Layouts.page>
|
||||
<.breadcrumbs>
|
||||
<:crumb navigate={~p"/"}>registry</:crumb>
|
||||
<:crumb>report</:crumb>
|
||||
</.breadcrumbs>
|
||||
|
||||
<main id="moderation-report" class="border-2 border-strong">
|
||||
<header class="border-b-2 border-strong bg-panel px-5 py-6 sm:px-8 sm:py-8">
|
||||
<h1 class="font-display text-3xl uppercase tracking-[0.02em] text-ink sm:text-4xl">
|
||||
Report content
|
||||
</h1>
|
||||
<p class="mt-3 max-w-2xl text-sm leading-6 text-ink-muted">
|
||||
Flag unsafe, abusive, or fabricated material for restricted review.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section class="px-5 py-6 sm:px-8 sm:py-8">
|
||||
<.form for={@form} id="moderation-report-form" phx-submit="submit">
|
||||
<div class="grid gap-4 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
||||
<.input
|
||||
field={@form[:subject_type]}
|
||||
type="select"
|
||||
label="Content type"
|
||||
options={@subject_options}
|
||||
/>
|
||||
<.input
|
||||
field={@form[:subject_id]}
|
||||
type="number"
|
||||
min="1"
|
||||
label="Content ID"
|
||||
placeholder="123"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<.input
|
||||
field={@form[:reason]}
|
||||
type="select"
|
||||
label="Reason"
|
||||
options={@reason_options}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<.input
|
||||
field={@form[:description]}
|
||||
type="textarea"
|
||||
label="What should moderators review?"
|
||||
minlength="10"
|
||||
maxlength="5000"
|
||||
rows="8"
|
||||
placeholder="Describe the specific problem and where it appears. Do not include secrets or unnecessary personal data."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end border-t border-rule pt-5">
|
||||
<button
|
||||
id="submit-moderation-report"
|
||||
type="submit"
|
||||
class="clip-notch bg-btn px-5 py-2.5 font-display text-xs uppercase tracking-[0.12em] text-btn-fg transition hover:opacity-90 phx-submit-loading:opacity-60"
|
||||
>
|
||||
Submit report
|
||||
</button>
|
||||
</div>
|
||||
</.form>
|
||||
</section>
|
||||
</main>
|
||||
</Layouts.page>
|
||||
</Layouts.app>
|
||||
83
lib/tarakan_web/live/page_live.ex
Normal file
83
lib/tarakan_web/live/page_live.ex
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
defmodule TarakanWeb.PageLive do
|
||||
@moduledoc """
|
||||
Static policy and pricing pages.
|
||||
|
||||
These have no interactivity, but they are LiveViews so navigating to them
|
||||
from the nav is a live patch rather than a full page reload. As plain
|
||||
controller pages they were the only routes in the shell without a LiveSocket,
|
||||
which made them behave differently from everything around them.
|
||||
|
||||
One module, one action per page, templates in `page_live/`.
|
||||
"""
|
||||
use TarakanWeb, :live_view
|
||||
|
||||
embed_templates "page_live/*"
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok, assign(socket, :security_contact, Application.get_env(:tarakan, :security_contact))}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(_params, _uri, socket) do
|
||||
{:noreply, apply_action(socket, socket.assigns.live_action)}
|
||||
end
|
||||
|
||||
defp apply_action(socket, :disclosure) do
|
||||
socket
|
||||
|> assign(:page_title, "Disclosure policy")
|
||||
|> assign(
|
||||
:meta_description,
|
||||
"Tarakan is public disclosure by default: visibility levels, verification quorum, " <>
|
||||
"vendor notification, and abuse reporting."
|
||||
)
|
||||
|> assign(:canonical_path, ~p"/policies/disclosure")
|
||||
end
|
||||
|
||||
defp apply_action(socket, :content) do
|
||||
socket
|
||||
|> assign(:page_title, "Content policy")
|
||||
|> assign(
|
||||
:meta_description,
|
||||
"What may and may not be published to the Tarakan public security record, and how " <>
|
||||
"takedowns and appeals work."
|
||||
)
|
||||
|> assign(:canonical_path, ~p"/policies/content")
|
||||
end
|
||||
|
||||
defp apply_action(socket, :pricing) do
|
||||
take_rate_percent =
|
||||
Tarakan.Market.take_rate()
|
||||
|> Decimal.mult(100)
|
||||
|> Decimal.normalize()
|
||||
|> Decimal.to_string(:normal)
|
||||
|
||||
socket
|
||||
|> assign(:page_title, "Pricing")
|
||||
|> assign(:take_rate_percent, take_rate_percent)
|
||||
|> assign(
|
||||
:meta_description,
|
||||
"Tarakan is free: read, submit, and check security findings at no cost. Post a " <>
|
||||
"contract to get specific code reviewed; Tarakan keeps #{take_rate_percent}% when " <>
|
||||
"the work is accepted."
|
||||
)
|
||||
|> assign(:canonical_path, ~p"/pricing")
|
||||
end
|
||||
|
||||
defp apply_action(socket, :managed_disclosure) do
|
||||
socket
|
||||
|> assign(:page_title, "Managed disclosure")
|
||||
|> assign(
|
||||
:meta_description,
|
||||
"Managed disclosure: Tarakan staff triage findings with the vendor under an SLA. " <>
|
||||
"Handling, not suppression - the public record is unchanged."
|
||||
)
|
||||
|> assign(:canonical_path, ~p"/services/disclosure")
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(%{live_action: :disclosure} = assigns), do: disclosure(assigns)
|
||||
def render(%{live_action: :content} = assigns), do: content(assigns)
|
||||
def render(%{live_action: :pricing} = assigns), do: pricing(assigns)
|
||||
def render(%{live_action: :managed_disclosure} = assigns), do: managed_disclosure(assigns)
|
||||
end
|
||||
66
lib/tarakan_web/live/page_live/content.html.heex
Normal file
66
lib/tarakan_web/live/page_live/content.html.heex
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
|
||||
<Layouts.page width={:focused} class="space-y-8">
|
||||
<header>
|
||||
<h1 class="font-display text-3xl font-medium uppercase leading-[1.05] tracking-[0.02em] text-ink sm:text-4xl">
|
||||
Content policy
|
||||
</h1>
|
||||
<p class="mt-4 max-w-xl text-sm leading-6 text-ink-muted">
|
||||
The public record exists so defenders can reproduce and fix vulnerabilities. Content
|
||||
that serves offense instead of defense is removed.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section id="allowed-content" class="border-t-2 border-strong pt-6">
|
||||
<h2 class="font-display text-sm uppercase tracking-[0.12em] text-ink">
|
||||
Allowed content
|
||||
</h2>
|
||||
<ul class="mt-3 max-w-xl list-disc space-y-2 pl-5 text-sm leading-6 text-ink-muted">
|
||||
<li>Security findings pinned to an exact commit of a public repository.</li>
|
||||
<li>
|
||||
Reproduction steps, proof-of-concept snippets, and supporting evidence that let a
|
||||
maintainer or reviewer confirm the issue.
|
||||
</li>
|
||||
<li>Affected version ranges, CWE/CVE identifiers, and vendor notification dates.</li>
|
||||
<li>Reviewer verdicts and the notes needed to justify them.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="prohibited-content" class="border-t-2 border-strong pt-6">
|
||||
<h2 class="font-display text-sm uppercase tracking-[0.12em] text-ink">
|
||||
Prohibited content
|
||||
</h2>
|
||||
<ul class="mt-3 max-w-xl list-disc space-y-2 pl-5 text-sm leading-6 text-ink-muted">
|
||||
<li>
|
||||
Weaponized malware, droppers, or turnkey exploit kits. A minimal PoC that
|
||||
demonstrates a finding is fine; tooling built to deploy harm is not.
|
||||
</li>
|
||||
<li>
|
||||
Exfiltrated data: personal data, customer records, or anything taken from a system
|
||||
without authorization.
|
||||
</li>
|
||||
<li>
|
||||
Secrets and credentials - API keys, tokens, private keys, passwords. Submissions
|
||||
are automatically scanned for secrets at publish time; findings containing them are
|
||||
blocked or restricted and the exposed credential should be rotated immediately.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="takedown-appeals" class="border-t-2 border-strong pt-6">
|
||||
<h2 class="font-display text-sm uppercase tracking-[0.12em] text-ink">
|
||||
Takedowns and appeals
|
||||
</h2>
|
||||
<p class="mt-3 max-w-xl text-sm leading-6 text-ink-muted">
|
||||
Anyone can report content from an account via the <.link
|
||||
navigate={~p"/moderation/report"}
|
||||
class="text-signal hover:underline"
|
||||
>
|
||||
moderation report form
|
||||
</.link>. Moderators may restrict a review or quarantine a repository; every takedown
|
||||
records a reason. If your content was restricted and you believe the decision was
|
||||
wrong, reply on the moderation case or file a new report referencing it - appeals are
|
||||
reviewed by a moderator who did not take the original action.
|
||||
</p>
|
||||
</section>
|
||||
</Layouts.page>
|
||||
</Layouts.app>
|
||||
110
lib/tarakan_web/live/page_live/disclosure.html.heex
Normal file
110
lib/tarakan_web/live/page_live/disclosure.html.heex
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
|
||||
<Layouts.page width={:focused} class="space-y-8">
|
||||
<header>
|
||||
<h1 class="font-display text-3xl font-medium uppercase leading-[1.05] tracking-[0.02em] text-ink sm:text-4xl">
|
||||
Disclosure policy
|
||||
</h1>
|
||||
<p class="mt-4 max-w-xl text-sm leading-6 text-ink-muted">
|
||||
Tarakan is a public security record. Reviews are published the moment they are
|
||||
submitted; verification and moderation change a record's labels, not its existence.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section id="public-by-default" class="border-t-2 border-strong pt-6">
|
||||
<h2 class="font-display text-sm uppercase tracking-[0.12em] text-ink">
|
||||
Public by default
|
||||
</h2>
|
||||
<p class="mt-3 max-w-xl text-sm leading-6 text-ink-muted">
|
||||
Every submitted review is public immediately. Submissions are evidence, not verdicts:
|
||||
a finding appears on the record as soon as it is reported, and independent reviewers
|
||||
confirm, dispute, or mark it fixed in the open. There is no embargo period and no
|
||||
private queue between submission and publication.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="visibility-levels" class="border-t-2 border-strong pt-6">
|
||||
<h2 class="font-display text-sm uppercase tracking-[0.12em] text-ink">
|
||||
Visibility levels
|
||||
</h2>
|
||||
<p class="mt-3 max-w-xl text-sm leading-6 text-ink-muted">
|
||||
Visibility is the only content gate. Each review carries one of three levels:
|
||||
</p>
|
||||
<dl class="mt-4 divide-y divide-rule border-2 border-strong">
|
||||
<div class="px-4 py-3">
|
||||
<dt class="font-mono text-xs uppercase tracking-[0.12em] text-ink">public</dt>
|
||||
<dd class="mt-1 text-sm leading-6 text-ink-muted">
|
||||
The default. Full finding details: title, description, file and line references,
|
||||
reproduction steps, affected versions, and CWE/CVE identifiers.
|
||||
</dd>
|
||||
</div>
|
||||
<div class="px-4 py-3">
|
||||
<dt class="font-mono text-xs uppercase tracking-[0.12em] text-ink">public_summary</dt>
|
||||
<dd class="mt-1 text-sm leading-6 text-ink-muted">
|
||||
The review exists and counts toward repository headlines, but finding bodies,
|
||||
verdict evidence, and notes are withheld from anonymous readers.
|
||||
</dd>
|
||||
</div>
|
||||
<div class="px-4 py-3">
|
||||
<dt class="font-mono text-xs uppercase tracking-[0.12em] text-ink">restricted</dt>
|
||||
<dd class="mt-1 text-sm leading-6 text-ink-muted">
|
||||
A moderation takedown. The review is invisible to anonymous readers; only
|
||||
moderators and the submitting account can see it. Restriction exists only as an
|
||||
explicit moderation decision, never as a pre-publication state.
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section id="verification" class="border-t-2 border-strong pt-6">
|
||||
<h2 class="font-display text-sm uppercase tracking-[0.12em] text-ink">
|
||||
Verification quorum
|
||||
</h2>
|
||||
<p class="mt-3 max-w-xl text-sm leading-6 text-ink-muted">
|
||||
Findings open as <span class="font-mono text-xs text-ink">open</span>. Independent
|
||||
reviewers re-run or re-trace the pinned commit and record checks. When two independent
|
||||
checks agree - the quorum threshold - the finding moves to <span class="font-mono text-xs text-ink">verified</span>, <span class="font-mono text-xs text-ink">disputed</span>, or <span class="font-mono text-xs text-ink">fixed</span>. Reviewers cannot verify their
|
||||
own submissions. Status labels reflect the record's current consensus; they never hide
|
||||
the underlying report.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="vendor-notification" class="border-t-2 border-strong pt-6">
|
||||
<h2 class="font-display text-sm uppercase tracking-[0.12em] text-ink">
|
||||
Vendor notification
|
||||
</h2>
|
||||
<p class="mt-3 max-w-xl text-sm leading-6 text-ink-muted">
|
||||
Because the record is public on submission, maintainers learn about findings at the
|
||||
same time as everyone else. Qualified contributors can record the date a vendor was
|
||||
notified on each finding page; the recorded date is displayed publicly alongside the
|
||||
finding.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="abuse" class="border-t-2 border-strong pt-6">
|
||||
<h2 class="font-display text-sm uppercase tracking-[0.12em] text-ink">
|
||||
Abuse and moderation
|
||||
</h2>
|
||||
<p class="mt-3 max-w-xl text-sm leading-6 text-ink-muted">
|
||||
To report abuse, prohibited content, or a disclosure that endangers someone, file a
|
||||
report from any account via the <.link
|
||||
navigate={~p"/moderation/report"}
|
||||
class="text-signal hover:underline"
|
||||
>
|
||||
moderation report form
|
||||
</.link>. Moderators review reports in a public-interest queue and may restrict
|
||||
content or quarantine repositories.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="security-contact" class="border-t-2 border-strong pt-6">
|
||||
<h2 class="font-display text-sm uppercase tracking-[0.12em] text-ink">
|
||||
Security contact
|
||||
</h2>
|
||||
<p class="mt-3 max-w-xl text-sm leading-6 text-ink-muted">
|
||||
To report a vulnerability in Tarakan itself, use the contact published in our
|
||||
<a href="/.well-known/security.txt" class="text-signal hover:underline">security.txt</a>
|
||||
(RFC 9116).
|
||||
</p>
|
||||
</section>
|
||||
</Layouts.page>
|
||||
</Layouts.app>
|
||||
93
lib/tarakan_web/live/page_live/managed_disclosure.html.heex
Normal file
93
lib/tarakan_web/live/page_live/managed_disclosure.html.heex
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
|
||||
<Layouts.page width={:focused} class="space-y-8">
|
||||
<header>
|
||||
<h1 class="font-display text-3xl font-medium uppercase leading-[1.05] tracking-[0.02em] text-ink sm:text-4xl">
|
||||
Managed disclosure
|
||||
</h1>
|
||||
<p class="mt-4 max-w-xl text-sm leading-6 text-ink-muted">
|
||||
Managed disclosure is professional handling for vendors: Tarakan staff triage
|
||||
the findings on your repository's public record and run the conversation with
|
||||
you under an agreed response SLA. It changes how findings are handled, never
|
||||
whether they are public.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section id="what-it-is" class="border-t-2 border-strong pt-6">
|
||||
<h2 class="font-display text-sm uppercase tracking-[0.12em] text-ink">
|
||||
What it is
|
||||
</h2>
|
||||
<p class="mt-3 max-w-xl text-sm leading-6 text-ink-muted">
|
||||
Tarakan is public disclosure by default: findings appear on the record the
|
||||
moment they are reported. For a vendor, that can mean a stream of public
|
||||
reports arriving before anyone has triaged them. Managed disclosure adds a
|
||||
staffed layer on top of the record:
|
||||
</p>
|
||||
<dl class="mt-4 divide-y divide-rule border-2 border-strong">
|
||||
<div class="px-4 py-3">
|
||||
<dt class="font-mono text-xs uppercase tracking-[0.12em] text-ink">Triage</dt>
|
||||
<dd class="mt-1 text-sm leading-6 text-ink-muted">
|
||||
Staff review new findings on your repositories as they land: deduplicating
|
||||
against known issues, assessing severity and reach, and separating
|
||||
reproducible reports from noise before they consume your team's time.
|
||||
</dd>
|
||||
</div>
|
||||
<div class="px-4 py-3">
|
||||
<dt class="font-mono text-xs uppercase tracking-[0.12em] text-ink">
|
||||
SLA'd response
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm leading-6 text-ink-muted">
|
||||
Every new finding gets a first staff response within an agreed window, and
|
||||
you get a direct channel to the people handling it - no parsing a public
|
||||
feed to learn what matters.
|
||||
</dd>
|
||||
</div>
|
||||
<div class="px-4 py-3">
|
||||
<dt class="font-mono text-xs uppercase tracking-[0.12em] text-ink">
|
||||
Professional handling
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm leading-6 text-ink-muted">
|
||||
Coordinated communication with reporters, recorded vendor-notification
|
||||
dates on each finding, and a single point of contact for escalations,
|
||||
takedown reviews, and disputes.
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section id="and-the-public-record" class="border-t-2 border-strong pt-6">
|
||||
<h2 class="font-display text-sm uppercase tracking-[0.12em] text-ink">
|
||||
Handling, not suppression
|
||||
</h2>
|
||||
<p class="mt-3 max-w-xl text-sm leading-6 text-ink-muted">
|
||||
Managed disclosure never hides a verified finding. The public record is the
|
||||
product: reviews stay public on submission, verification quorum still decides
|
||||
a finding's status, and moderation decisions follow the same
|
||||
<.link navigate={~p"/policies/disclosure"} class="text-signal hover:underline">
|
||||
disclosure policy
|
||||
</.link>
|
||||
for every repository, managed or not. What the service changes is who helps
|
||||
you respond, and how fast. Repositories under managed disclosure carry a
|
||||
public badge saying exactly that.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="intake" class="border-t-2 border-strong pt-6">
|
||||
<h2 class="font-display text-sm uppercase tracking-[0.12em] text-ink">
|
||||
Intake
|
||||
</h2>
|
||||
<p class="mt-3 max-w-xl text-sm leading-6 text-ink-muted">
|
||||
To put a repository under managed disclosure, write to
|
||||
<a
|
||||
id="managed-disclosure-contact"
|
||||
href={"mailto:#{@security_contact}?subject=Tarakan%20managed%20disclosure"}
|
||||
class="text-signal hover:underline"
|
||||
>
|
||||
{@security_contact}
|
||||
</a>
|
||||
with the repository, your role on the project, and a verification contact.
|
||||
Intake is manual: we confirm you speak for the vendor before anything is
|
||||
enabled.
|
||||
</p>
|
||||
</section>
|
||||
</Layouts.page>
|
||||
</Layouts.app>
|
||||
136
lib/tarakan_web/live/page_live/pricing.html.heex
Normal file
136
lib/tarakan_web/live/page_live/pricing.html.heex
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
|
||||
<Layouts.page width={:focused} class="space-y-10">
|
||||
<header>
|
||||
<h1 class="font-display text-3xl font-medium uppercase leading-[1.05] tracking-[0.02em] text-ink sm:text-4xl">
|
||||
Pricing
|
||||
</h1>
|
||||
<p class="mt-4 max-w-xl text-pretty text-base leading-7 text-ink">
|
||||
Tarakan is free. Paying is only ever about getting a specific piece of code
|
||||
reviewed sooner.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section id="pricing-free">
|
||||
<h2 class="font-display text-sm uppercase tracking-[0.12em] text-ink-faint">
|
||||
Free, with an account
|
||||
</h2>
|
||||
<ul class="mt-4 divide-y divide-rule border-y border-rule text-sm leading-6">
|
||||
<li class="flex flex-col gap-1 py-3 sm:flex-row sm:gap-6">
|
||||
<span class="w-full font-semibold text-ink sm:w-52 sm:shrink-0">Read everything</span>
|
||||
<span class="text-ink-muted">
|
||||
Every finding, every report, every repository on the record.
|
||||
</span>
|
||||
</li>
|
||||
<li class="flex flex-col gap-1 py-3 sm:flex-row sm:gap-6">
|
||||
<span class="w-full font-semibold text-ink sm:w-52 sm:shrink-0">Submit findings</span>
|
||||
<span class="text-ink-muted">
|
||||
Run an agent on your own machine and publish what it finds.
|
||||
</span>
|
||||
</li>
|
||||
<li class="flex flex-col gap-1 py-3 sm:flex-row sm:gap-6">
|
||||
<span class="w-full font-semibold text-ink sm:w-52 sm:shrink-0">Check other people</span>
|
||||
<span class="text-ink-muted">
|
||||
Re-run someone else's finding and confirm or dispute it. This earns credits.
|
||||
</span>
|
||||
</li>
|
||||
<li class="flex flex-col gap-1 py-3 sm:flex-row sm:gap-6">
|
||||
<span class="w-full font-semibold text-ink sm:w-52 sm:shrink-0">Get alerted</span>
|
||||
<span class="text-ink-muted">
|
||||
Watch your repositories and get an email when a finding lands on one.
|
||||
</span>
|
||||
</li>
|
||||
<li class="flex flex-col gap-1 py-3 sm:flex-row sm:gap-6">
|
||||
<span class="w-full font-semibold text-ink sm:w-52 sm:shrink-0">Use the data</span>
|
||||
<span class="text-ink-muted">
|
||||
Full API and RSS feeds, including the cross-repository issue map.
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<.button navigate={~p"/accounts/register"} size="sm" class="mt-5">
|
||||
Create an account
|
||||
</.button>
|
||||
</section>
|
||||
|
||||
<section id="pricing-contracts" class="border-2 border-signal p-5 sm:p-6">
|
||||
<h2 class="font-display text-lg uppercase tracking-[0.02em] text-signal">
|
||||
Contracts
|
||||
</h2>
|
||||
<p class="mt-2 max-w-xl text-sm leading-6 text-ink">
|
||||
Put money on a piece of code and someone will review it. You choose the target and
|
||||
the amount. There is no minimum and no monthly fee.
|
||||
</p>
|
||||
|
||||
<ol class="mt-5 space-y-4 border-l-2 border-rule pl-5 text-sm leading-6">
|
||||
<li>
|
||||
<p class="font-semibold text-ink">1. You post it</p>
|
||||
<p class="mt-0.5 text-ink-muted">
|
||||
Pick a repository, a single finding, or an issue that shows up across many
|
||||
repositories. Name your price. Your card is charged now.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p class="font-semibold text-ink">2. We hold the money</p>
|
||||
<p class="mt-0.5 text-ink-muted">
|
||||
Nobody is paid until the work is done and accepted. If the contract expires
|
||||
unclaimed, you are refunded.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p class="font-semibold text-ink">3. Someone does the work</p>
|
||||
<p class="mt-0.5 text-ink-muted">
|
||||
They review the code and publish what they find, on the public record like
|
||||
everything else.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p class="font-semibold text-ink">4. They get paid</p>
|
||||
<p class="mt-0.5 text-ink-muted">
|
||||
Tarakan keeps {@take_rate_percent}%. That is the only cut we take, and we only
|
||||
take it when the work is accepted.
|
||||
</p>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<p class="mt-5 max-w-xl border-t border-rule pt-4 text-sm leading-6 text-ink-muted">
|
||||
<span class="font-semibold text-ink">Free alternative:</span>
|
||||
checking other people's findings earns credits, and credits fund contracts in place
|
||||
of money. Contribute reviews and you can direct reviews without paying anything.
|
||||
</p>
|
||||
|
||||
<.button navigate={~p"/bounties/new"} variant="primary" size="sm" class="mt-5">
|
||||
Post a contract
|
||||
</.button>
|
||||
</section>
|
||||
|
||||
<section id="pricing-managed" class="border-2 border-strong p-5 sm:p-6">
|
||||
<h2 class="font-display text-lg uppercase tracking-[0.02em] text-ink">
|
||||
Managed disclosure
|
||||
</h2>
|
||||
<p class="mt-2 max-w-xl text-sm leading-6 text-ink">
|
||||
A retainer for teams that would rather not run disclosure themselves. Our staff take
|
||||
findings to the vendor, agree timelines, and follow up. Billed by invoice.
|
||||
</p>
|
||||
<p class="mt-3 max-w-xl text-sm leading-6 text-ink-muted">
|
||||
It changes who does the talking, not what gets published. The record stays public
|
||||
either way.
|
||||
</p>
|
||||
<.button
|
||||
id="pricing-managed-contact"
|
||||
href={"mailto:#{@security_contact}?subject=Tarakan%20managed%20disclosure"}
|
||||
size="sm"
|
||||
class="mt-5"
|
||||
>
|
||||
Contact us
|
||||
</.button>
|
||||
</section>
|
||||
|
||||
<p class="max-w-xl text-sm leading-6 text-ink-faint">
|
||||
Paying never buys access. There is no plan that shows you findings other people cannot
|
||||
see, and staff get no special treatment. Payments are handled by Stripe and you can
|
||||
cancel a retainer from your <.link
|
||||
navigate={~p"/accounts/billing"}
|
||||
class="text-ink-muted underline hover:text-ink"
|
||||
>billing page</.link>.
|
||||
</p>
|
||||
</Layouts.page>
|
||||
</Layouts.app>
|
||||
856
lib/tarakan_web/live/repository_code_live.ex
Normal file
856
lib/tarakan_web/live/repository_code_live.ex
Normal file
|
|
@ -0,0 +1,856 @@
|
|||
defmodule TarakanWeb.RepositoryCodeLive do
|
||||
use TarakanWeb, :live_view
|
||||
|
||||
alias Tarakan.Repositories
|
||||
alias Tarakan.Repositories.Repository
|
||||
alias Tarakan.RepositoryCode
|
||||
alias Tarakan.RepositoryCode.{File, Tree}
|
||||
alias Tarakan.RepositoryPath
|
||||
alias Tarakan.Scans
|
||||
alias TarakanWeb.RepositoryPaths
|
||||
|
||||
@commit_sha_pattern ~r/^[0-9a-f]{40}$/
|
||||
@line_range_pattern ~r/^([1-9][0-9]{0,8})(?:-([1-9][0-9]{0,8}))?$/
|
||||
@max_rendered_lines 10_000
|
||||
@max_selected_line 1_000_000
|
||||
@max_selected_span 1_000
|
||||
|
||||
@impl true
|
||||
def mount(params, _session, socket) do
|
||||
{action, params} = normalize_route_params(socket.assigns.live_action, params)
|
||||
socket = assign(socket, :live_action, action)
|
||||
|
||||
case source_from_params(action, params, socket) do
|
||||
{:ok, source} ->
|
||||
repository = source.repository
|
||||
|
||||
if connected?(socket) do
|
||||
Repositories.subscribe()
|
||||
Scans.subscribe(repository.id)
|
||||
end
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Code · #{repository.owner}/#{repository.name}")
|
||||
|> assign(:repository, repository)
|
||||
|> assign(:clone_urls, RepositoryPaths.clone_urls(repository))
|
||||
|> assign(:finding, source.finding)
|
||||
|> assign(:finding_ref, source.finding && source.finding.public_id)
|
||||
|> assign(:source_commit_sha, source.commit_sha)
|
||||
|> assign(:source_path, source.path)
|
||||
|> assign(:source_line_range, source.line_range)
|
||||
|> assign(:commit_sha, nil)
|
||||
|> assign(:path, "")
|
||||
|> assign(:path_segments, [])
|
||||
|> assign(:line_range, nil)
|
||||
|> assign(:line_range_invalid?, false)
|
||||
|> assign(:view_state, :loading)
|
||||
|> assign(:browser_kind, nil)
|
||||
|> assign(:tree, nil)
|
||||
|> assign(:file, nil)
|
||||
|> assign(:entry_count, 0)
|
||||
|> assign(:visible_finding_count, 0)
|
||||
|> assign(:line_count, 0)
|
||||
|> assign(:line_range_outside_file?, false)
|
||||
|> assign(:suspicious_controls?, false)
|
||||
|> assign(:error_kind, nil)
|
||||
|> assign(:request_id, nil)
|
||||
|> assign(:branch_options, [])
|
||||
|> assign(:selected_branch, repository.default_branch)
|
||||
|> maybe_load_branch_options(action)
|
||||
|> stream_configure(:entries, dom_id: &entry_dom_id/1)
|
||||
|> stream_configure(:lines, dom_id: &line_dom_id/1)
|
||||
|> stream(:entries, [])
|
||||
|> stream(:lines, [])}
|
||||
|
||||
{:error, :not_found} ->
|
||||
raise Ecto.NoResultsError, queryable: Repository
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(params, _uri, socket) do
|
||||
{action, params} = normalize_route_params(socket.assigns.live_action, params)
|
||||
|
||||
socket =
|
||||
case action do
|
||||
:entry -> prepare_entry_request(socket)
|
||||
:finding -> prepare_finding_request(socket)
|
||||
:show -> prepare_code_request(socket, params)
|
||||
end
|
||||
|
||||
{:noreply, maybe_start_request(assign(socket, :live_action, action))}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("retry", _params, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:view_state, :loading)
|
||||
|> assign(:error_kind, nil)
|
||||
|> maybe_start_request()}
|
||||
end
|
||||
|
||||
# Switch the code browser to another branch tip (still commit-pinned under the hood).
|
||||
def handle_event("select_branch", %{"branch" => branch}, socket) do
|
||||
if socket.assigns.live_action == :finding do
|
||||
{:noreply, put_flash(socket, :error, "Finding context is pinned to its report commit.")}
|
||||
else
|
||||
repository = socket.assigns.repository
|
||||
branch = String.trim(to_string(branch || ""))
|
||||
|
||||
case RepositoryCode.resolve_branch_commit(repository, branch) do
|
||||
{:ok, commit_sha} ->
|
||||
path =
|
||||
if is_binary(socket.assigns.path) and socket.assigns.path != "" do
|
||||
split_path(socket.assigns.path)
|
||||
else
|
||||
[]
|
||||
end
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:selected_branch, branch)
|
||||
|> push_navigate(to: code_path(repository, commit_sha, path))}
|
||||
|
||||
{:error, _reason} ->
|
||||
{:noreply,
|
||||
put_flash(socket, :error, "Could not resolve branch #{inspect(branch)} to a commit.")}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_async(:browse, {:ok, {request_id, result}}, socket) do
|
||||
if request_id == socket.assigns.request_id do
|
||||
case reauthorize_view(socket) do
|
||||
{:ok, authorized_socket} ->
|
||||
{:noreply, apply_browse_result(authorized_socket, result)}
|
||||
|
||||
{:error, :not_found} ->
|
||||
{:noreply, evict_source(socket)}
|
||||
end
|
||||
else
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_async(:browse, {:exit, _reason}, socket) do
|
||||
{:noreply, assign_error(socket, :unavailable)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(
|
||||
{:repository_record_updated, %Repository{id: repository_id}},
|
||||
%{assigns: %{repository: %Repository{id: repository_id}}} = socket
|
||||
) do
|
||||
case reauthorize_view(socket) do
|
||||
{:ok, authorized_socket} -> {:noreply, refresh_finding_badges(authorized_socket)}
|
||||
{:error, :not_found} -> {:noreply, evict_source(socket)}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_info({event, %Repository{}}, socket)
|
||||
when event in [:repository_registered, :repository_record_updated] do
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_info(
|
||||
{:scan_updated, %{id: scan_id}},
|
||||
%{assigns: %{live_action: :finding, finding: %{scan_id: scan_id}}} = socket
|
||||
) do
|
||||
case reauthorize_view(socket) do
|
||||
{:ok, authorized_socket} -> {:noreply, authorized_socket}
|
||||
{:error, :not_found} -> {:noreply, evict_source(socket)}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_info({event, _scan}, socket) when event in [:scan_submitted, :scan_updated],
|
||||
do: {:noreply, refresh_finding_badges(socket)}
|
||||
|
||||
defp source_from_params(:finding, %{"finding_ref" => finding_ref}, socket) do
|
||||
with {:ok, {scan, finding}} <- Scans.get_finding(socket.assigns.current_scope, finding_ref),
|
||||
%Repository{} = repository <-
|
||||
Repositories.get_visible_repository(
|
||||
scan.repository.host,
|
||||
scan.repository.owner,
|
||||
scan.repository.name,
|
||||
socket.assigns.current_scope
|
||||
) do
|
||||
{:ok,
|
||||
%{
|
||||
repository: repository,
|
||||
finding: finding,
|
||||
commit_sha: scan.commit_sha,
|
||||
path: finding.file_path,
|
||||
line_range: finding_line_range(finding)
|
||||
}}
|
||||
end
|
||||
end
|
||||
|
||||
defp source_from_params(_action, %{"host" => slug, "owner" => owner, "name" => name}, socket) do
|
||||
with {:ok, host} <- Tarakan.Hosts.host_for_slug(slug),
|
||||
%Repository{} = repository <-
|
||||
Repositories.get_visible_repository(host, owner, name, socket.assigns.current_scope) do
|
||||
{:ok,
|
||||
%{
|
||||
repository: repository,
|
||||
finding: nil,
|
||||
commit_sha: nil,
|
||||
path: nil,
|
||||
line_range: nil
|
||||
}}
|
||||
else
|
||||
_not_visible -> {:error, :not_found}
|
||||
end
|
||||
end
|
||||
|
||||
defp source_from_params(_action, _params, _socket), do: {:error, :not_found}
|
||||
|
||||
# Resolves the bare GitHub-style routes (/owner/name...), which carry no
|
||||
# :host segment. A handle-like first segment is a Tarakan-hosted
|
||||
# repository. A host-like first segment means the bare pattern's literal
|
||||
# ("code") swallowed a remote path - a repository literally named "code" -
|
||||
# so shift the params: /github.com/rails/code[/code[/sha[/*path]]].
|
||||
# The security tab of such a repository has no reachable URL form; those
|
||||
# requests fall through to :not_found.
|
||||
defp normalize_route_params(action, %{"owner" => owner, "name" => name} = params)
|
||||
when not is_map_key(params, "host") do
|
||||
if Tarakan.Hosts.host_segment?(owner) do
|
||||
remote = %{"host" => owner, "owner" => name, "name" => "code"}
|
||||
|
||||
case {action, params["commit_sha"], params["path"]} do
|
||||
{:code_entry, _sha, _path} ->
|
||||
{:entry, remote}
|
||||
|
||||
{:show, "code", nil} ->
|
||||
{:entry, remote}
|
||||
|
||||
{:show, "code", [commit_sha | path]} ->
|
||||
{:show, remote |> Map.put("commit_sha", commit_sha) |> Map.put("path", path)}
|
||||
|
||||
_other ->
|
||||
{action, params}
|
||||
end
|
||||
else
|
||||
{action_without_code_alias(action), Map.put(params, "host", Repository.hosted_host())}
|
||||
end
|
||||
end
|
||||
|
||||
defp normalize_route_params(action, params), do: {action_without_code_alias(action), params}
|
||||
|
||||
# /:owner/:name/code is a distinct live action only so bare-route
|
||||
# reinterpretation can tell it apart from /:owner/:name; everywhere else
|
||||
# it behaves exactly like :entry.
|
||||
defp action_without_code_alias(:code_entry), do: :entry
|
||||
defp action_without_code_alias(action), do: action
|
||||
|
||||
defp prepare_entry_request(socket) do
|
||||
prepare_request(socket, :resolve_entry, nil, "", nil, false)
|
||||
end
|
||||
|
||||
defp prepare_finding_request(socket) do
|
||||
prepare_request(
|
||||
socket,
|
||||
:browse_finding,
|
||||
socket.assigns.source_commit_sha,
|
||||
socket.assigns.source_path,
|
||||
socket.assigns.source_line_range,
|
||||
false
|
||||
)
|
||||
end
|
||||
|
||||
defp prepare_code_request(socket, params) do
|
||||
with {:ok, commit_sha} <- normalize_commit_sha(params["commit_sha"]),
|
||||
{:ok, path} <- normalize_route_path(params["path"]),
|
||||
{:ok, line_range, line_range_invalid?} <- parse_line_range(params["lines"]) do
|
||||
socket
|
||||
|> prepare_request(
|
||||
:browse,
|
||||
commit_sha,
|
||||
path,
|
||||
line_range,
|
||||
line_range_invalid?
|
||||
)
|
||||
|> sync_selected_branch(commit_sha)
|
||||
else
|
||||
{:error, reason} -> assign_error(socket, reason)
|
||||
end
|
||||
end
|
||||
|
||||
# After a branch switch (or deep link to a tip SHA), keep the picker in sync.
|
||||
defp sync_selected_branch(socket, commit_sha) do
|
||||
case RepositoryCode.branch_for_commit(socket.assigns.repository, commit_sha) do
|
||||
{:ok, branch} -> assign(socket, :selected_branch, branch)
|
||||
_unknown -> socket
|
||||
end
|
||||
end
|
||||
|
||||
defp prepare_request(
|
||||
socket,
|
||||
request_kind,
|
||||
commit_sha,
|
||||
path,
|
||||
line_range,
|
||||
line_range_invalid?
|
||||
) do
|
||||
request_id = System.unique_integer([:positive, :monotonic])
|
||||
|
||||
socket
|
||||
|> assign(:request_id, request_id)
|
||||
|> assign(:request_kind, request_kind)
|
||||
|> assign(:commit_sha, commit_sha)
|
||||
|> assign(:path, path)
|
||||
|> assign(:path_segments, split_path(path))
|
||||
|> assign(:line_range, line_range)
|
||||
|> assign(:line_range_invalid?, line_range_invalid?)
|
||||
|> assign(:view_state, :loading)
|
||||
|> assign(:browser_kind, nil)
|
||||
|> assign(:tree, nil)
|
||||
|> assign(:file, nil)
|
||||
|> assign(:entry_count, 0)
|
||||
|> assign(:visible_finding_count, 0)
|
||||
|> assign(:line_count, 0)
|
||||
|> assign(:line_range_outside_file?, false)
|
||||
|> assign(:suspicious_controls?, false)
|
||||
|> assign(:error_kind, nil)
|
||||
|> stream(:entries, [], reset: true)
|
||||
|> stream(:lines, [], reset: true)
|
||||
end
|
||||
|
||||
defp maybe_start_request(%{assigns: %{view_state: :loading}} = socket) do
|
||||
if connected?(socket) do
|
||||
request_id = socket.assigns.request_id
|
||||
request_kind = socket.assigns.request_kind
|
||||
repository = socket.assigns.repository
|
||||
commit_sha = socket.assigns.commit_sha
|
||||
path = socket.assigns.path
|
||||
# Per-IP browsing budgets are enforced at the HTTP layer by
|
||||
# TarakanWeb.Plugs.CodeBrowserRateLimit; the reads below stay on the
|
||||
# default (non-bypassed) code path.
|
||||
socket
|
||||
|> cancel_async(:browse)
|
||||
|> start_async(:browse, fn ->
|
||||
{request_id, run_request(request_kind, repository, commit_sha, path)}
|
||||
end)
|
||||
else
|
||||
socket
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_start_request(socket), do: socket
|
||||
|
||||
defp run_request(:browse, repository, commit_sha, path) do
|
||||
# Generic browser may open any *current* branch tip (not only default),
|
||||
# but still rejects guessed historical SHAs that are not branch tips.
|
||||
with :ok <- RepositoryCode.authorize_public_commit(repository, commit_sha),
|
||||
{:ok, result} <- RepositoryCode.browse(repository, commit_sha, path, []) do
|
||||
{:ok, result}
|
||||
else
|
||||
# Never show the old "busy network" page for GitHub REST quota.
|
||||
{:error, :rate_limited} -> {:error, :unavailable}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp run_request(:browse_finding, repository, commit_sha, path) do
|
||||
case RepositoryCode.browse(repository, commit_sha, path, []) do
|
||||
{:error, :rate_limited} -> {:error, :unavailable}
|
||||
other -> other
|
||||
end
|
||||
end
|
||||
|
||||
defp run_request(:resolve_entry, repository, _commit_sha, _path) do
|
||||
with {:ok, commit_sha} <- RepositoryCode.resolve_default_commit(repository, []),
|
||||
{:ok, result} <- RepositoryCode.browse(repository, commit_sha, "", []) do
|
||||
{:ok, result}
|
||||
else
|
||||
{:error, :rate_limited} -> {:error, :unavailable}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp apply_browse_result(socket, {:ok, %Tree{truncated: true}}) do
|
||||
assign_error(socket, :tree_truncated)
|
||||
end
|
||||
|
||||
defp apply_browse_result(socket, {:ok, %Tree{} = tree}) do
|
||||
finding_paths = visible_finding_paths(socket, tree.commit_sha)
|
||||
|
||||
entries =
|
||||
tree.entries
|
||||
|> sort_entries()
|
||||
|> Enum.map(&entry_with_finding_count(&1, finding_paths))
|
||||
|
||||
socket
|
||||
|> assign(:view_state, :ready)
|
||||
|> assign(:browser_kind, :tree)
|
||||
|> assign(:commit_sha, tree.commit_sha)
|
||||
|> assign(:path, tree.path)
|
||||
|> assign(:path_segments, split_path(tree.path))
|
||||
|> assign(:tree, tree)
|
||||
|> assign(:file, nil)
|
||||
|> assign(:entry_count, length(entries))
|
||||
|> assign(:visible_finding_count, length(finding_paths))
|
||||
|> assign(:line_count, 0)
|
||||
|> assign(:line_range_outside_file?, false)
|
||||
|> assign(:suspicious_controls?, false)
|
||||
|> assign(:error_kind, nil)
|
||||
|> stream(:entries, entries, reset: true)
|
||||
|> stream(:lines, [], reset: true)
|
||||
end
|
||||
|
||||
defp apply_browse_result(socket, {:ok, %File{} = file}) do
|
||||
case file_lines(file.content, socket.assigns.line_range) do
|
||||
{:ok, lines} ->
|
||||
line_count = length(lines)
|
||||
finding_count = visible_file_finding_count(socket, file.commit_sha, file.path)
|
||||
|
||||
socket
|
||||
|> assign(:view_state, :ready)
|
||||
|> assign(:browser_kind, :file)
|
||||
|> assign(:commit_sha, file.commit_sha)
|
||||
|> assign(:path, file.path)
|
||||
|> assign(:path_segments, split_path(file.path))
|
||||
|> assign(:tree, nil)
|
||||
|> assign(:file, file)
|
||||
|> assign(:entry_count, 0)
|
||||
|> assign(:visible_finding_count, finding_count)
|
||||
|> assign(:line_count, line_count)
|
||||
|> assign(
|
||||
:line_range_outside_file?,
|
||||
line_range_outside_file?(socket.assigns.line_range, line_count)
|
||||
)
|
||||
|> assign(:suspicious_controls?, suspicious_source_controls?(file.content))
|
||||
|> assign(:error_kind, nil)
|
||||
|> stream(:entries, [], reset: true)
|
||||
|> stream(:lines, lines, reset: true)
|
||||
|
||||
{:error, :too_many_lines} ->
|
||||
assign_error(socket, :blob_too_large)
|
||||
end
|
||||
end
|
||||
|
||||
defp apply_browse_result(socket, {:ok, commit_sha}) when is_binary(commit_sha) do
|
||||
case normalize_commit_sha(commit_sha) do
|
||||
{:ok, commit_sha} ->
|
||||
push_navigate(socket,
|
||||
to: code_path(socket.assigns.repository, commit_sha, [])
|
||||
)
|
||||
|
||||
{:error, _reason} ->
|
||||
assign_error(socket, :invalid_response)
|
||||
end
|
||||
end
|
||||
|
||||
defp apply_browse_result(socket, {:error, reason}), do: assign_error(socket, reason)
|
||||
defp apply_browse_result(socket, _unexpected), do: assign_error(socket, :invalid_response)
|
||||
|
||||
defp assign_error(socket, reason) do
|
||||
socket
|
||||
|> assign(:view_state, :error)
|
||||
|> assign(:browser_kind, nil)
|
||||
|> assign(:tree, nil)
|
||||
|> assign(:file, nil)
|
||||
|> assign(:entry_count, 0)
|
||||
|> assign(:visible_finding_count, 0)
|
||||
|> assign(:line_count, 0)
|
||||
|> assign(:line_range_outside_file?, false)
|
||||
|> assign(:suspicious_controls?, false)
|
||||
|> assign(:error_kind, normalize_error(reason))
|
||||
|> stream(:entries, [], reset: true)
|
||||
|> stream(:lines, [], reset: true)
|
||||
end
|
||||
|
||||
defp normalize_error(reason)
|
||||
when reason in [
|
||||
:invalid_commit_sha,
|
||||
:invalid_path,
|
||||
:empty_repository,
|
||||
:identity_changed,
|
||||
:commit_mismatch,
|
||||
:not_found,
|
||||
:tree_truncated,
|
||||
:tree_too_large,
|
||||
:blob_too_large,
|
||||
:binary_blob,
|
||||
:unsupported_entry,
|
||||
:rate_limited,
|
||||
:unavailable,
|
||||
:invalid_response
|
||||
],
|
||||
do: reason
|
||||
|
||||
defp normalize_error(_reason), do: :unavailable
|
||||
|
||||
defp normalize_commit_sha(commit_sha) when is_binary(commit_sha) do
|
||||
normalized = String.downcase(commit_sha)
|
||||
|
||||
if Regex.match?(@commit_sha_pattern, normalized) do
|
||||
{:ok, normalized}
|
||||
else
|
||||
{:error, :invalid_commit_sha}
|
||||
end
|
||||
end
|
||||
|
||||
defp normalize_commit_sha(_commit_sha), do: {:error, :invalid_commit_sha}
|
||||
|
||||
defp normalize_route_path(nil), do: {:ok, ""}
|
||||
|
||||
defp normalize_route_path(path) when is_list(path) do
|
||||
path
|
||||
|> Enum.join("/")
|
||||
|> RepositoryPath.normalize()
|
||||
end
|
||||
|
||||
defp normalize_route_path(_path), do: {:error, :invalid_path}
|
||||
|
||||
defp parse_line_range(nil), do: {:ok, nil, false}
|
||||
defp parse_line_range(""), do: {:ok, nil, false}
|
||||
|
||||
defp parse_line_range(value) when is_binary(value) do
|
||||
case Regex.run(@line_range_pattern, value, capture: :all_but_first) do
|
||||
[first, ""] -> validate_single_line(first)
|
||||
[first] -> validate_single_line(first)
|
||||
[first, last] -> validate_line_range(String.to_integer(first), String.to_integer(last))
|
||||
nil -> {:ok, nil, true}
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_line_range(_value), do: {:ok, nil, true}
|
||||
|
||||
defp validate_single_line(line) do
|
||||
line = String.to_integer(line)
|
||||
validate_line_range(line, line)
|
||||
end
|
||||
|
||||
defp validate_line_range(first, last)
|
||||
when last >= first and last <= @max_selected_line and
|
||||
last - first <= @max_selected_span,
|
||||
do: {:ok, {first, last}, false}
|
||||
|
||||
defp validate_line_range(_first, _last), do: {:ok, nil, true}
|
||||
|
||||
defp finding_line_range(%{line_start: line_start, line_end: line_end})
|
||||
when is_integer(line_start) do
|
||||
{line_start, line_end || line_start}
|
||||
end
|
||||
|
||||
defp finding_line_range(_finding), do: nil
|
||||
|
||||
defp split_path(""), do: []
|
||||
defp split_path(path), do: String.split(path, "/", trim: true)
|
||||
|
||||
defp file_lines("", _line_range), do: {:ok, []}
|
||||
|
||||
defp file_lines(content, line_range) do
|
||||
lines = String.split(content, "\n", parts: @max_rendered_lines + 1, trim: false)
|
||||
|
||||
if length(lines) > @max_rendered_lines do
|
||||
{:error, :too_many_lines}
|
||||
else
|
||||
{:ok,
|
||||
lines
|
||||
|> Enum.with_index(1)
|
||||
|> Enum.map(fn {content, number} ->
|
||||
%{number: number, content: content, highlighted?: line_selected?(number, line_range)}
|
||||
end)}
|
||||
end
|
||||
end
|
||||
|
||||
defp line_selected?(_number, nil), do: false
|
||||
defp line_selected?(number, {first, last}), do: number >= first and number <= last
|
||||
|
||||
defp line_range_outside_file?(nil, _line_count), do: false
|
||||
|
||||
defp line_range_outside_file?({first, last}, line_count),
|
||||
do: first > line_count or last > line_count
|
||||
|
||||
defp sort_entries(entries) do
|
||||
Enum.sort_by(entries, fn entry ->
|
||||
{entry_type_order(entry.type), String.downcase(entry.name), entry.name}
|
||||
end)
|
||||
end
|
||||
|
||||
defp entry_with_finding_count(entry, finding_paths) do
|
||||
count =
|
||||
case entry.type do
|
||||
:tree ->
|
||||
prefix = entry.path <> "/"
|
||||
Enum.count(finding_paths, &String.starts_with?(&1, prefix))
|
||||
|
||||
:blob ->
|
||||
Enum.count(finding_paths, &(&1 == entry.path))
|
||||
|
||||
_unsupported ->
|
||||
0
|
||||
end
|
||||
|
||||
entry
|
||||
|> Map.from_struct()
|
||||
|> Map.put(:finding_count, count)
|
||||
end
|
||||
|
||||
defp visible_file_finding_count(socket, commit_sha, path) do
|
||||
socket
|
||||
|> visible_finding_paths(commit_sha)
|
||||
|> Enum.count(&(&1 == path))
|
||||
end
|
||||
|
||||
defp visible_finding_paths(socket, commit_sha) do
|
||||
socket.assigns.current_scope
|
||||
|> Scans.list_scans(socket.assigns.repository)
|
||||
|> Enum.filter(
|
||||
&(&1.commit_sha == commit_sha and &1.details_visible and &1.review_status != "rejected")
|
||||
)
|
||||
|> Enum.flat_map(& &1.findings)
|
||||
|> Enum.map(& &1.file_path)
|
||||
end
|
||||
|
||||
defp refresh_finding_badges(%{assigns: %{browser_kind: :tree, tree: %Tree{} = tree}} = socket),
|
||||
do: apply_browse_result(socket, {:ok, tree})
|
||||
|
||||
defp refresh_finding_badges(%{assigns: %{browser_kind: :file, file: %File{} = file}} = socket) do
|
||||
assign(
|
||||
socket,
|
||||
:visible_finding_count,
|
||||
visible_file_finding_count(socket, file.commit_sha, file.path)
|
||||
)
|
||||
end
|
||||
|
||||
defp refresh_finding_badges(socket), do: socket
|
||||
|
||||
defp entry_type_order(:tree), do: 0
|
||||
defp entry_type_order(:blob), do: 1
|
||||
defp entry_type_order(:symlink), do: 2
|
||||
defp entry_type_order(:submodule), do: 3
|
||||
|
||||
defp entry_dom_id(entry) do
|
||||
encoded_path = Base.url_encode64(entry.path, padding: false)
|
||||
"code-entry-#{encoded_path}"
|
||||
end
|
||||
|
||||
defp line_dom_id(line), do: "L#{line.number}"
|
||||
|
||||
defp repository_record_path(repository), do: RepositoryPaths.repository_path(repository)
|
||||
|
||||
defp repository_security_path(repository),
|
||||
do: RepositoryPaths.repository_security_path(repository)
|
||||
|
||||
defp code_path(repository, commit_sha, path_segments),
|
||||
do: RepositoryPaths.repository_code_path(repository, commit_sha, path_segments)
|
||||
|
||||
# Finding views stay on their report pin; free browse can switch branches.
|
||||
defp maybe_load_branch_options(socket, :finding), do: socket
|
||||
|
||||
defp maybe_load_branch_options(socket, _action) do
|
||||
repository = socket.assigns.repository
|
||||
|
||||
case RepositoryCode.list_branches(repository) do
|
||||
{:ok, branches} ->
|
||||
socket
|
||||
|> assign(:branch_options, branches)
|
||||
|> assign(
|
||||
:selected_branch,
|
||||
socket.assigns.selected_branch || repository.default_branch || List.first(branches)
|
||||
)
|
||||
|
||||
{:error, _} ->
|
||||
default = repository.default_branch
|
||||
branches = if is_binary(default) and default != "", do: [default], else: []
|
||||
|
||||
socket
|
||||
|> assign(:branch_options, branches)
|
||||
|> assign(:selected_branch, default)
|
||||
end
|
||||
end
|
||||
|
||||
defp breadcrumb_items(path_segments) do
|
||||
path_segments
|
||||
|> Enum.with_index()
|
||||
|> Enum.map(fn {name, index} ->
|
||||
%{name: name, segments: Enum.take(path_segments, index + 1)}
|
||||
end)
|
||||
end
|
||||
|
||||
defp entry_path(repository, commit_sha, entry) do
|
||||
code_path(repository, commit_sha, split_path(entry.path))
|
||||
end
|
||||
|
||||
defp breadcrumb_path(repository, commit_sha, segments) do
|
||||
code_path(repository, commit_sha, segments)
|
||||
end
|
||||
|
||||
defp line_path(live_action, finding_ref, repository, commit_sha, path_segments, line_number) do
|
||||
base =
|
||||
if live_action == :finding do
|
||||
~p"/findings/#{finding_ref}/code"
|
||||
else
|
||||
code_path(repository, commit_sha, path_segments)
|
||||
end
|
||||
|
||||
if live_action == :finding do
|
||||
base <> "#L#{line_number}"
|
||||
else
|
||||
base <> "?lines=#{line_number}#L#{line_number}"
|
||||
end
|
||||
end
|
||||
|
||||
defp format_bytes(nil), do: "-"
|
||||
defp format_bytes(bytes) when bytes < 1_024, do: "#{bytes} B"
|
||||
|
||||
defp format_bytes(bytes) when bytes < 1_024 * 1_024,
|
||||
do: "#{Float.round(bytes / 1_024, 1)} KB"
|
||||
|
||||
defp format_bytes(bytes), do: "#{Float.round(bytes / (1_024 * 1_024), 1)} MB"
|
||||
|
||||
defp short_sha(sha), do: String.slice(sha, 0, 7)
|
||||
|
||||
defp entry_icon(:tree), do: "hero-folder"
|
||||
defp entry_icon(:blob), do: "hero-document-text"
|
||||
defp entry_icon(:symlink), do: "hero-link"
|
||||
defp entry_icon(:submodule), do: "hero-cube"
|
||||
|
||||
defp entry_label(:symlink), do: "symbolic link"
|
||||
defp entry_label(:submodule), do: "submodule"
|
||||
|
||||
defp error_title(:invalid_commit_sha), do: "Invalid commit"
|
||||
defp error_title(:invalid_path), do: "Invalid path"
|
||||
defp error_title(:not_found), do: "Source not found"
|
||||
defp error_title(:binary_blob), do: "Binary file"
|
||||
defp error_title(:blob_too_large), do: "File too large"
|
||||
defp error_title(:tree_too_large), do: "Directory too large"
|
||||
defp error_title(:tree_truncated), do: "Incomplete directory"
|
||||
defp error_title(:unsupported_entry), do: "Unsupported source entry"
|
||||
defp error_title(:empty_repository), do: "Empty repository"
|
||||
defp error_title(:rate_limited), do: "Code unavailable"
|
||||
defp error_title(:identity_changed), do: "Repository identity changed"
|
||||
defp error_title(:commit_mismatch), do: "Commit verification failed"
|
||||
defp error_title(_error), do: "Code unavailable"
|
||||
|
||||
defp error_message(:invalid_commit_sha),
|
||||
do: "Code views require a full 40-character commit SHA."
|
||||
|
||||
defp error_message(:invalid_path), do: "That repository path is not valid."
|
||||
|
||||
defp error_message(:not_found),
|
||||
do: "No source exists at this path in the pinned commit."
|
||||
|
||||
defp error_message(:binary_blob),
|
||||
do: "Tarakan does not render binary files as source code."
|
||||
|
||||
defp error_message(:blob_too_large),
|
||||
do: "This file exceeds the safe source-preview limit."
|
||||
|
||||
defp error_message(reason) when reason in [:tree_too_large, :tree_truncated],
|
||||
do: "This directory cannot be represented safely as a complete listing."
|
||||
|
||||
defp error_message(:unsupported_entry),
|
||||
do: "This Git object type cannot be opened in the source browser."
|
||||
|
||||
defp error_message(:empty_repository),
|
||||
do: "Nothing has been pushed to this repository yet. Push a branch and it will appear here."
|
||||
|
||||
defp error_message(:rate_limited),
|
||||
do:
|
||||
"Could not load this commit from git or GitHub yet. Wait a few seconds for the mirror to fill, then retry."
|
||||
|
||||
defp error_message(:unavailable),
|
||||
do:
|
||||
"Could not load this commit from git or GitHub yet. Wait a few seconds for the mirror to fill, then retry."
|
||||
|
||||
defp error_message(reason) when reason in [:identity_changed, :commit_mismatch],
|
||||
do: "Tarakan could not verify this source against the registered public repository."
|
||||
|
||||
defp error_message(_error),
|
||||
do:
|
||||
"Could not load this commit from git or GitHub yet. Wait a few seconds for the mirror to fill, then retry."
|
||||
|
||||
defp suspicious_source_controls?(content) do
|
||||
String.contains?(content, <<0>>) or
|
||||
Enum.any?([0x202A, 0x202B, 0x202C, 0x202D, 0x202E, 0x2066, 0x2067, 0x2068, 0x2069], fn
|
||||
codepoint -> String.contains?(content, <<codepoint::utf8>>)
|
||||
end)
|
||||
end
|
||||
|
||||
defp reauthorize_view(%{assigns: %{live_action: :finding}} = socket) do
|
||||
repository = socket.assigns.repository
|
||||
|
||||
with %Repository{} = visible_repository <-
|
||||
Repositories.get_visible_repository(
|
||||
repository.host,
|
||||
repository.owner,
|
||||
repository.name,
|
||||
socket.assigns.current_scope
|
||||
),
|
||||
{:ok, {scan, finding}} <-
|
||||
Scans.get_finding(socket.assigns.current_scope, socket.assigns.finding_ref),
|
||||
true <- scan.repository_id == visible_repository.id,
|
||||
true <- scan.commit_sha == socket.assigns.source_commit_sha,
|
||||
true <- finding.file_path == socket.assigns.source_path do
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:repository, visible_repository)
|
||||
|> assign(:finding, finding)}
|
||||
else
|
||||
_not_visible -> {:error, :not_found}
|
||||
end
|
||||
end
|
||||
|
||||
defp reauthorize_view(socket) do
|
||||
repository = socket.assigns.repository
|
||||
|
||||
case Repositories.get_visible_repository(
|
||||
repository.host,
|
||||
repository.owner,
|
||||
repository.name,
|
||||
socket.assigns.current_scope
|
||||
) do
|
||||
%Repository{} = visible_repository ->
|
||||
{:ok, assign(socket, :repository, visible_repository)}
|
||||
|
||||
nil ->
|
||||
{:error, :not_found}
|
||||
end
|
||||
end
|
||||
|
||||
defp evict_source(socket) do
|
||||
repository = socket.assigns.repository
|
||||
|
||||
destination =
|
||||
case Repositories.get_visible_repository(
|
||||
repository.host,
|
||||
repository.owner,
|
||||
repository.name,
|
||||
socket.assigns.current_scope
|
||||
) do
|
||||
%Repository{} = visible_repository -> repository_record_path(visible_repository)
|
||||
nil -> ~p"/"
|
||||
end
|
||||
|
||||
socket
|
||||
|> clear_source()
|
||||
|> push_navigate(to: destination)
|
||||
end
|
||||
|
||||
defp clear_source(socket) do
|
||||
socket
|
||||
|> assign(:view_state, :loading)
|
||||
|> assign(:browser_kind, nil)
|
||||
|> assign(:commit_sha, nil)
|
||||
|> assign(:path, "")
|
||||
|> assign(:path_segments, [])
|
||||
|> assign(:line_range, nil)
|
||||
|> assign(:finding, nil)
|
||||
|> assign(:tree, nil)
|
||||
|> assign(:file, nil)
|
||||
|> assign(:entry_count, 0)
|
||||
|> assign(:visible_finding_count, 0)
|
||||
|> assign(:line_count, 0)
|
||||
|> assign(:line_range_outside_file?, false)
|
||||
|> assign(:suspicious_controls?, false)
|
||||
|> assign(:error_kind, nil)
|
||||
|> stream(:entries, [], reset: true)
|
||||
|> stream(:lines, [], reset: true)
|
||||
end
|
||||
end
|
||||
338
lib/tarakan_web/live/repository_code_live.html.heex
Normal file
338
lib/tarakan_web/live/repository_code_live.html.heex
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
|
||||
<Layouts.page>
|
||||
<.repository_header repository={@repository} active_tab={:code} />
|
||||
|
||||
<section
|
||||
id="code-reference"
|
||||
class="mt-5 flex flex-col gap-3 rounded-md border border-rule bg-panel px-3 py-2.5 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<%= if @live_action == :finding or @branch_options == [] do %>
|
||||
<span class="inline-flex items-center gap-2 rounded-md border border-rule bg-ground px-3 py-1.5 font-mono text-[11px] text-ink">
|
||||
<.icon name="hero-code-bracket" class="size-3.5 text-ink-faint" />
|
||||
{@selected_branch || @repository.default_branch || "commit"}
|
||||
</span>
|
||||
<% else %>
|
||||
<form id="code-branch-form" phx-change="select_branch" class="min-w-0">
|
||||
<label for="code-branch-select" class="sr-only">Branch</label>
|
||||
<select
|
||||
id="code-branch-select"
|
||||
name="branch"
|
||||
class="max-w-[14rem] truncate rounded-md border border-rule bg-ground px-2.5 py-1.5 font-mono text-[11px] text-ink focus:border-signal focus:outline-none"
|
||||
>
|
||||
<option
|
||||
:for={branch <- @branch_options}
|
||||
value={branch}
|
||||
selected={branch == @selected_branch}
|
||||
>
|
||||
{branch}{if branch == @repository.default_branch, do: " (default)", else: ""}
|
||||
</option>
|
||||
</select>
|
||||
</form>
|
||||
<% end %>
|
||||
<span class="text-ink-faint" aria-hidden="true">·</span>
|
||||
<span
|
||||
id="code-commit-sha"
|
||||
class="font-mono text-[11px] text-ink-muted"
|
||||
title={@commit_sha}
|
||||
>
|
||||
<.icon name="hero-clock" class="mr-1 inline size-3.5" />
|
||||
{if @commit_sha, do: short_sha(@commit_sha), else: "Resolving commit"}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 font-mono text-[10px] text-ink-faint">
|
||||
<.link
|
||||
:if={@visible_finding_count > 0}
|
||||
navigate={repository_security_path(@repository)}
|
||||
class="inline-flex items-center gap-1.5 text-signal"
|
||||
>
|
||||
<.icon name="hero-shield-exclamation" class="size-3.5" />
|
||||
{@visible_finding_count} {if @visible_finding_count == 1,
|
||||
do: "finding",
|
||||
else: "findings"}
|
||||
</.link>
|
||||
<%!-- Hosted status lives in the repository header only. --%>
|
||||
<span :if={is_nil(@clone_urls)}>Read-only</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
:if={@clone_urls}
|
||||
id="clone-urls"
|
||||
class="mt-3 flex flex-col gap-2 rounded-md border border-rule bg-panel px-3 py-2.5"
|
||||
>
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<span class="font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint">https</span>
|
||||
<code id="clone-url-https" class="break-all font-mono text-[11px] text-ink">
|
||||
{@clone_urls.https}
|
||||
</code>
|
||||
</div>
|
||||
<div :if={@clone_urls.ssh} class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<span class="font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint">ssh</span>
|
||||
<code id="clone-url-ssh" class="break-all font-mono text-[11px] text-ink">
|
||||
{@clone_urls.ssh}
|
||||
</code>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
:if={@finding}
|
||||
id="finding-code-context"
|
||||
class="mt-4 border-l-4 border-signal bg-panel px-4 py-3"
|
||||
>
|
||||
<p class="text-sm font-semibold text-ink">{@finding.title}</p>
|
||||
<p class="mt-1 text-xs leading-5 text-ink-muted">
|
||||
Tarakan checks the finding's stored file and line range against this exact commit.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<%= if @commit_sha do %>
|
||||
<nav
|
||||
id="source-breadcrumbs"
|
||||
class="mt-4 flex min-w-0 flex-wrap items-center gap-1 rounded-t-md border border-b-0 border-rule bg-ground px-4 py-3 font-mono text-xs"
|
||||
aria-label="Source path"
|
||||
>
|
||||
<%= if @live_action == :finding do %>
|
||||
<span class="text-ink-muted">{@repository.name}</span>
|
||||
<% else %>
|
||||
<.link
|
||||
navigate={code_path(@repository, @commit_sha, [])}
|
||||
class="text-ink-muted transition hover:text-signal"
|
||||
>
|
||||
{@repository.name}
|
||||
</.link>
|
||||
<% end %>
|
||||
<%= for item <- breadcrumb_items(@path_segments) do %>
|
||||
<span class="text-ink-faint" aria-hidden="true">/</span>
|
||||
<%= if @live_action == :finding do %>
|
||||
<span class="break-all text-ink">{item.name}</span>
|
||||
<% else %>
|
||||
<.link
|
||||
navigate={breadcrumb_path(@repository, @commit_sha, item.segments)}
|
||||
class="break-all text-ink-muted transition hover:text-signal"
|
||||
>
|
||||
{item.name}
|
||||
</.link>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</nav>
|
||||
<% end %>
|
||||
|
||||
<div
|
||||
:if={@view_state == :loading}
|
||||
id="code-browser-loading"
|
||||
class="border border-rule px-5 py-8 text-center"
|
||||
aria-live="polite"
|
||||
>
|
||||
<.icon name="hero-arrow-path" class="mx-auto size-5 text-ink-faint" />
|
||||
<p class="mt-4 font-display text-sm uppercase tracking-[0.12em] text-ink">
|
||||
Loading pinned source
|
||||
</p>
|
||||
<p class="mx-auto mt-2 max-w-md text-xs leading-5 text-ink-faint">
|
||||
Tarakan is verifying the public repository and exact commit before rendering code.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section
|
||||
:if={@view_state == :error}
|
||||
id="code-browser-error"
|
||||
class="border border-rule px-6 py-8 text-center"
|
||||
aria-live="polite"
|
||||
>
|
||||
<.icon name="hero-document-magnifying-glass" class="mx-auto size-7 text-ink-faint" />
|
||||
<h2 class="mt-4 font-display text-lg uppercase tracking-[0.12em] text-ink">
|
||||
{error_title(@error_kind)}
|
||||
</h2>
|
||||
<p class="mx-auto mt-2 max-w-lg text-sm leading-6 text-ink-muted">
|
||||
{error_message(@error_kind)}
|
||||
</p>
|
||||
<div
|
||||
:if={@error_kind == :empty_repository && @clone_urls}
|
||||
id="empty-repository-push-hint"
|
||||
class="mx-auto mt-5 max-w-lg border border-rule bg-ground px-4 py-3 text-left"
|
||||
>
|
||||
<pre class="overflow-x-auto font-mono text-[11px] leading-5 text-ink"><code>git remote add origin {@clone_urls.https}
|
||||
git push -u origin main</code></pre>
|
||||
</div>
|
||||
<button
|
||||
:if={@error_kind in [:rate_limited, :unavailable]}
|
||||
id="retry-code-browser"
|
||||
type="button"
|
||||
phx-click="retry"
|
||||
class="mt-5 border border-strong px-4 py-2 font-display text-xs uppercase tracking-[0.12em] text-ink transition hover:bg-panel"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<%= if @view_state == :ready && @browser_kind == :tree do %>
|
||||
<section id="code-tree" class="overflow-hidden rounded-b-md border border-rule">
|
||||
<header class="flex items-center justify-between gap-4 border-b border-rule bg-panel px-4 py-3">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<.icon name="hero-folder-open" class="size-4 shrink-0 text-ink-faint" />
|
||||
<h2 class="truncate font-mono text-xs text-ink">
|
||||
{if @path == "", do: "/", else: @path}
|
||||
</h2>
|
||||
</div>
|
||||
<span class="shrink-0 font-mono text-[10px] text-ink-faint">
|
||||
{@entry_count} {if @entry_count == 1, do: "entry", else: "entries"}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div id="code-tree-entries" phx-update="stream" class="divide-y divide-rule">
|
||||
<div
|
||||
id="code-tree-empty"
|
||||
class="hidden px-6 py-6 text-center text-sm text-ink-faint only:block"
|
||||
>
|
||||
This directory is empty at the pinned commit.
|
||||
</div>
|
||||
<div
|
||||
:for={{id, entry} <- @streams.entries}
|
||||
id={id}
|
||||
class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4 px-4 py-2.5 text-sm transition-colors hover:bg-panel"
|
||||
>
|
||||
<%= if entry.type in [:tree, :blob] do %>
|
||||
<.link
|
||||
navigate={entry_path(@repository, @commit_sha, entry)}
|
||||
class="flex min-w-0 items-center gap-3 text-ink transition hover:text-signal"
|
||||
>
|
||||
<.icon name={entry_icon(entry.type)} class="size-4 shrink-0 text-ink-faint" />
|
||||
<span class="truncate font-mono text-xs">{entry.name}</span>
|
||||
</.link>
|
||||
<% else %>
|
||||
<div class="flex min-w-0 items-center gap-3 text-ink-muted">
|
||||
<.icon name={entry_icon(entry.type)} class="size-4 shrink-0 text-ink-faint" />
|
||||
<span class="truncate font-mono text-xs">{entry.name}</span>
|
||||
<span class="font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint">
|
||||
{entry_label(entry.type)}
|
||||
</span>
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<.link
|
||||
:if={entry.finding_count > 0}
|
||||
id={"#{id}-findings"}
|
||||
navigate={repository_security_path(@repository)}
|
||||
class="inline-flex items-center gap-1 rounded-full border border-signal px-2 py-0.5 font-mono text-[10px] font-semibold text-signal"
|
||||
aria-label={"View security findings affecting #{entry.path}"}
|
||||
>
|
||||
<.icon name="hero-shield-exclamation" class="size-3" />
|
||||
{entry.finding_count}
|
||||
<span class="hidden sm:inline">
|
||||
{if entry.finding_count == 1, do: "finding", else: "findings"}
|
||||
</span>
|
||||
</.link>
|
||||
<span class="min-w-14 text-right font-mono text-[10px] tabular-nums text-ink-faint">
|
||||
{format_bytes(entry.size)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<% end %>
|
||||
|
||||
<%= if @view_state == :ready && @browser_kind == :file do %>
|
||||
<section id="code-file" class="overflow-hidden rounded-b-md border border-rule">
|
||||
<header class="flex flex-col gap-2 border-b border-rule bg-panel px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<.icon name="hero-document-text" class="size-4 shrink-0 text-ink-faint" />
|
||||
<h2 class="truncate font-mono text-xs text-ink" title={@file.path}>
|
||||
{@file.path}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-3 font-mono text-[10px] text-ink-faint">
|
||||
<.link
|
||||
:if={@visible_finding_count > 0}
|
||||
navigate={repository_security_path(@repository)}
|
||||
class="inline-flex items-center gap-1 rounded-full border border-signal px-2 py-0.5 text-signal"
|
||||
>
|
||||
<.icon name="hero-shield-exclamation" class="size-3" />
|
||||
{@visible_finding_count} {if @visible_finding_count == 1,
|
||||
do: "finding",
|
||||
else: "findings"}
|
||||
</.link>
|
||||
<span>{@line_count} {if @line_count == 1, do: "line", else: "lines"}</span>
|
||||
<span>{format_bytes(@file.size)}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div
|
||||
:if={@line_range_invalid?}
|
||||
id="invalid-line-selection"
|
||||
class="border-b border-rule px-4 py-2 text-xs text-ink-muted"
|
||||
>
|
||||
The requested line selection was invalid, so no lines are highlighted.
|
||||
</div>
|
||||
|
||||
<div
|
||||
:if={@line_range_outside_file?}
|
||||
id="finding-line-outside-file"
|
||||
class="border-b border-rule px-4 py-2 text-xs text-ink-muted"
|
||||
>
|
||||
The reported line range is outside this file at the pinned commit.
|
||||
</div>
|
||||
|
||||
<div
|
||||
:if={@suspicious_controls?}
|
||||
id="source-control-warning"
|
||||
class="border-b-2 border-signal px-4 py-3 text-xs leading-5 text-ink"
|
||||
role="alert"
|
||||
>
|
||||
This file contains bidirectional text controls or NUL bytes. Tarakan preserves and renders
|
||||
the source as plain text; inspect it carefully.
|
||||
</div>
|
||||
|
||||
<div
|
||||
:if={@line_count == 0}
|
||||
id="code-file-empty"
|
||||
class="px-6 py-6 text-center text-sm text-ink-faint"
|
||||
>
|
||||
This file is empty at the pinned commit.
|
||||
</div>
|
||||
|
||||
<div :if={@line_count > 0} class="overflow-x-auto">
|
||||
<table class="w-full border-collapse font-mono text-xs leading-5">
|
||||
<tbody id="code-lines" phx-update="stream">
|
||||
<tr
|
||||
:for={{id, line} <- @streams.lines}
|
||||
id={id}
|
||||
class={[
|
||||
"group scroll-mt-24 align-top",
|
||||
line.highlighted? && "bg-panel"
|
||||
]}
|
||||
>
|
||||
<th
|
||||
scope="row"
|
||||
class={[
|
||||
"sticky left-0 w-px select-none border-r border-rule bg-ground px-3 py-0 text-right font-normal tabular-nums text-ink-faint",
|
||||
line.highlighted? && "border-l-4 border-l-signal bg-panel text-ink"
|
||||
]}
|
||||
>
|
||||
<a
|
||||
href={
|
||||
line_path(
|
||||
@live_action,
|
||||
@finding_ref,
|
||||
@repository,
|
||||
@commit_sha,
|
||||
@path_segments,
|
||||
line.number
|
||||
)
|
||||
}
|
||||
class="block min-w-8 transition group-hover:text-signal"
|
||||
aria-label={"Select line #{line.number}"}
|
||||
>
|
||||
{line.number}
|
||||
</a>
|
||||
</th>
|
||||
<td class="w-full py-0 pl-4 pr-8 text-ink">
|
||||
<code class="block min-h-5 whitespace-pre">{line.content}</code>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<% end %>
|
||||
</Layouts.page>
|
||||
</Layouts.app>
|
||||
322
lib/tarakan_web/live/repository_commits_live.ex
Normal file
322
lib/tarakan_web/live/repository_commits_live.ex
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
defmodule TarakanWeb.RepositoryCommitsLive do
|
||||
@moduledoc """
|
||||
Commit history of a repository's branches, plus a per-commit detail view
|
||||
with the full message and patch.
|
||||
|
||||
Every listed commit is reachable from a published branch tip, which is
|
||||
exactly the set `RepositoryCode.authorize_public_commit/2` allows in the
|
||||
code browser - so each commit links straight into the pinned source view.
|
||||
"""
|
||||
|
||||
use TarakanWeb, :live_view
|
||||
|
||||
alias Tarakan.Repositories
|
||||
alias Tarakan.Repositories.Repository
|
||||
alias Tarakan.RepositoryCode
|
||||
alias Tarakan.RepositoryCode.Commit
|
||||
alias TarakanWeb.RepositoryPaths
|
||||
|
||||
@page_size 50
|
||||
@max_diff_lines 5_000
|
||||
@commit_sha_pattern ~r/^[0-9a-f]{40}$/
|
||||
|
||||
@impl true
|
||||
def mount(params, _session, socket) do
|
||||
case repository_from_params(params, socket) do
|
||||
%Repository{} = repository ->
|
||||
if connected?(socket) do
|
||||
Repositories.subscribe()
|
||||
end
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:repository, repository)
|
||||
|> assign(:view_state, :loading)
|
||||
|> assign(:error_kind, nil)
|
||||
|> assign(:limit, @page_size)
|
||||
|> assign(:has_more?, false)
|
||||
|> assign(:branch_options, [])
|
||||
|> assign(:selected_branch, repository.default_branch)
|
||||
|> assign(:commit_sha, nil)
|
||||
|> assign(:commit, nil)
|
||||
|> maybe_load_branch_options()
|
||||
|> stream_configure(:commits, dom_id: &"commit-#{&1.sha}")
|
||||
|> stream(:commits, [])}
|
||||
|
||||
nil ->
|
||||
raise Ecto.NoResultsError, queryable: Repository
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(params, _uri, socket) do
|
||||
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("retry", _params, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:view_state, :loading)
|
||||
|> assign(:error_kind, nil)
|
||||
|> maybe_load()}
|
||||
end
|
||||
|
||||
def handle_event("load_more", _params, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:limit, socket.assigns.limit + @page_size)
|
||||
|> assign(:view_state, :loading)
|
||||
|> maybe_load_commits()}
|
||||
end
|
||||
|
||||
def handle_event("select_branch", %{"branch" => branch}, socket) do
|
||||
branch = String.trim(to_string(branch || ""))
|
||||
|
||||
if socket.assigns.live_action == :index and branch != "" do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:selected_branch, branch)
|
||||
|> assign(:limit, @page_size)
|
||||
|> assign(:view_state, :loading)
|
||||
|> maybe_load_commits()}
|
||||
else
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_async(:commits, {:ok, result}, socket) do
|
||||
case result do
|
||||
{:ok, %{commits: commits}} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:view_state, :ready)
|
||||
|> assign(:error_kind, nil)
|
||||
|> assign(:has_more?, length(commits) >= socket.assigns.limit)
|
||||
|> stream(:commits, commits, reset: true)}
|
||||
|
||||
{:error, reason} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:view_state, :error)
|
||||
|> assign(:error_kind, reason)
|
||||
|> stream(:commits, [], reset: true)}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_async(:commits, {:exit, _reason}, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:view_state, :error)
|
||||
|> assign(:error_kind, :unavailable)}
|
||||
end
|
||||
|
||||
def handle_async(:commit, {:ok, result}, socket) do
|
||||
case result do
|
||||
{:ok, %Commit{} = commit} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:view_state, :ready)
|
||||
|> assign(:error_kind, nil)
|
||||
|> assign(:commit, commit)
|
||||
|> assign(
|
||||
:page_title,
|
||||
"#{commit.subject} · #{socket.assigns.repository.owner}/#{socket.assigns.repository.name}"
|
||||
)}
|
||||
|
||||
{:error, reason} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:view_state, :error)
|
||||
|> assign(:error_kind, reason)
|
||||
|> assign(:commit, nil)}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_async(:commit, {:exit, _reason}, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:view_state, :error)
|
||||
|> assign(:error_kind, :unavailable)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(
|
||||
{:repository_record_updated, %Repository{id: repository_id}},
|
||||
%{assigns: %{repository: %Repository{id: repository_id} = repository}} = socket
|
||||
) do
|
||||
if Repositories.get_visible_repository(
|
||||
repository.host,
|
||||
repository.owner,
|
||||
repository.name,
|
||||
socket.assigns.current_scope
|
||||
) do
|
||||
{:noreply, socket}
|
||||
else
|
||||
{:noreply, push_navigate(socket, to: "/")}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_info({event, %Repository{}}, socket)
|
||||
when event in [:repository_registered, :repository_record_updated],
|
||||
do: {:noreply, socket}
|
||||
|
||||
defp apply_action(socket, :index, _params) do
|
||||
socket
|
||||
|> assign(
|
||||
:page_title,
|
||||
"Commits · #{socket.assigns.repository.owner}/#{socket.assigns.repository.name}"
|
||||
)
|
||||
|> assign(:view_state, :loading)
|
||||
|> assign(:error_kind, nil)
|
||||
|> assign(:commit_sha, nil)
|
||||
|> assign(:commit, nil)
|
||||
|> maybe_load_commits()
|
||||
end
|
||||
|
||||
defp apply_action(socket, :show, %{"commit_sha" => commit_sha}) do
|
||||
if Regex.match?(@commit_sha_pattern, commit_sha) do
|
||||
socket
|
||||
|> assign(:view_state, :loading)
|
||||
|> assign(:error_kind, nil)
|
||||
|> assign(:commit_sha, commit_sha)
|
||||
|> assign(:commit, nil)
|
||||
|> stream(:commits, [], reset: true)
|
||||
|> maybe_load_commit()
|
||||
else
|
||||
socket
|
||||
|> assign(:view_state, :error)
|
||||
|> assign(:error_kind, :not_found)
|
||||
|> assign(:commit_sha, nil)
|
||||
|> assign(:commit, nil)
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_load(socket) do
|
||||
case socket.assigns.live_action do
|
||||
:index -> maybe_load_commits(socket)
|
||||
:show -> maybe_load_commit(socket)
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_load_commits(socket) do
|
||||
if connected?(socket) do
|
||||
repository = socket.assigns.repository
|
||||
limit = socket.assigns.limit
|
||||
branch = selected_branch(socket)
|
||||
|
||||
start_async(socket, :commits, fn ->
|
||||
RepositoryCode.list_commits(repository, limit, branch: branch)
|
||||
end)
|
||||
else
|
||||
socket
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_load_commit(socket) do
|
||||
if connected?(socket) and is_binary(socket.assigns.commit_sha) do
|
||||
repository = socket.assigns.repository
|
||||
commit_sha = socket.assigns.commit_sha
|
||||
|
||||
start_async(socket, :commit, fn -> RepositoryCode.show_commit(repository, commit_sha) end)
|
||||
else
|
||||
socket
|
||||
end
|
||||
end
|
||||
|
||||
# The default branch goes through the default-tip resolution; any other
|
||||
# picker choice names the branch explicitly.
|
||||
defp selected_branch(socket) do
|
||||
case socket.assigns.selected_branch do
|
||||
branch when is_binary(branch) and branch != "" -> branch
|
||||
_other -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_load_branch_options(socket) do
|
||||
if connected?(socket) do
|
||||
case RepositoryCode.list_branches(socket.assigns.repository) do
|
||||
{:ok, branches} -> assign(socket, :branch_options, branches)
|
||||
{:error, _reason} -> socket
|
||||
end
|
||||
else
|
||||
socket
|
||||
end
|
||||
end
|
||||
|
||||
defp repository_from_params(params, socket) do
|
||||
with {:ok, host, owner, name} <- normalize_params(params),
|
||||
%Repository{} = repository <-
|
||||
Repositories.get_visible_repository(host, owner, name, socket.assigns.current_scope) do
|
||||
repository
|
||||
else
|
||||
_not_visible -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp normalize_params(%{"host" => slug, "owner" => owner, "name" => name}) do
|
||||
with {:ok, host} <- Tarakan.Hosts.host_for_slug(slug) do
|
||||
{:ok, host, owner, name}
|
||||
end
|
||||
end
|
||||
|
||||
# Bare GitHub-style route (/owner/name/commits...). A host-like first
|
||||
# segment means the bare pattern swallowed a remote path - a repository
|
||||
# literally named "commits" - so shift the params, same corner as
|
||||
# "code"/"security".
|
||||
defp normalize_params(%{"owner" => owner, "name" => name}) do
|
||||
if Tarakan.Hosts.host_segment?(owner) do
|
||||
normalize_params(%{"host" => owner, "owner" => name, "name" => "commits"})
|
||||
else
|
||||
{:ok, Repository.hosted_host(), owner, name}
|
||||
end
|
||||
end
|
||||
|
||||
defp normalize_params(_params), do: :error
|
||||
|
||||
defp short_sha(sha) when is_binary(sha), do: binary_part(sha, 0, 7)
|
||||
|
||||
defp commit_datetime(%DateTime{} = datetime),
|
||||
do: Calendar.strftime(datetime, "%Y-%m-%d %H:%M UTC")
|
||||
|
||||
defp commit_datetime(_other), do: "unknown date"
|
||||
|
||||
defp diff_lines(nil), do: []
|
||||
|
||||
defp diff_lines(patch) do
|
||||
patch
|
||||
|> String.split("\n")
|
||||
|> Enum.take(@max_diff_lines)
|
||||
|> Enum.map(&{diff_line_type(&1), &1})
|
||||
end
|
||||
|
||||
defp diff_line_over_limit?(nil), do: false
|
||||
defp diff_line_over_limit?(patch), do: length(String.split(patch, "\n")) > @max_diff_lines
|
||||
|
||||
defp diff_line_type("+++" <> _rest), do: :meta
|
||||
defp diff_line_type("---" <> _rest), do: :meta
|
||||
defp diff_line_type("+" <> _rest), do: :add
|
||||
defp diff_line_type("-" <> _rest), do: :del
|
||||
defp diff_line_type("@@" <> _rest), do: :hunk
|
||||
defp diff_line_type(_line), do: :context
|
||||
|
||||
defp diff_line_class(:add), do: "text-quote"
|
||||
defp diff_line_class(:del), do: "text-signal"
|
||||
defp diff_line_class(:hunk), do: "text-ink-faint"
|
||||
defp diff_line_class(:meta), do: "text-ink-faint"
|
||||
defp diff_line_class(:context), do: "text-ink"
|
||||
|
||||
defp error_title(:empty_repository), do: "No commits yet"
|
||||
defp error_title(:not_found), do: "Commit not found"
|
||||
defp error_title(_other), do: "History unavailable"
|
||||
|
||||
defp error_message(:empty_repository),
|
||||
do: "Nothing has been pushed to this repository yet."
|
||||
|
||||
defp error_message(:not_found),
|
||||
do: "This commit is not reachable from any published branch."
|
||||
|
||||
defp error_message(_other),
|
||||
do: "The commit history could not be read right now. Try again in a moment."
|
||||
end
|
||||
163
lib/tarakan_web/live/repository_commits_live.html.heex
Normal file
163
lib/tarakan_web/live/repository_commits_live.html.heex
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
|
||||
<Layouts.page>
|
||||
<.repository_header repository={@repository} active_tab={:commits} />
|
||||
|
||||
<section
|
||||
:if={@live_action == :index and @branch_options != []}
|
||||
id="commits-reference"
|
||||
class="mt-4 flex flex-col gap-3 rounded-md border border-rule bg-panel px-3 py-2.5 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<form id="commits-branch-form" phx-change="select_branch" class="min-w-0">
|
||||
<label for="commits-branch-select" class="sr-only">Branch</label>
|
||||
<select
|
||||
id="commits-branch-select"
|
||||
name="branch"
|
||||
class="max-w-[14rem] truncate rounded-md border border-rule bg-ground px-2.5 py-1.5 font-mono text-[11px] text-ink focus:border-signal focus:outline-none"
|
||||
>
|
||||
<option
|
||||
:for={branch <- @branch_options}
|
||||
value={branch}
|
||||
selected={branch == @selected_branch}
|
||||
>
|
||||
{branch}{if branch == @repository.default_branch, do: " (default)", else: ""}
|
||||
</option>
|
||||
</select>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<div
|
||||
:if={@view_state == :loading}
|
||||
id="commits-loading"
|
||||
class="mt-4 border border-rule px-5 py-8 text-center"
|
||||
aria-live="polite"
|
||||
>
|
||||
<.icon name="hero-arrow-path" class="mx-auto size-5 text-ink-faint" />
|
||||
<p class="mt-4 font-display text-sm uppercase tracking-[0.12em] text-ink">
|
||||
Loading commit history
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section
|
||||
:if={@view_state == :error}
|
||||
id="commits-error"
|
||||
class="mt-4 border border-rule px-6 py-8 text-center"
|
||||
aria-live="polite"
|
||||
>
|
||||
<.icon name="hero-clock" class="mx-auto size-7 text-ink-faint" />
|
||||
<h2 class="mt-4 font-display text-lg uppercase tracking-[0.12em] text-ink">
|
||||
{error_title(@error_kind)}
|
||||
</h2>
|
||||
<p class="mx-auto mt-2 max-w-lg text-sm leading-6 text-ink-muted">
|
||||
{error_message(@error_kind)}
|
||||
</p>
|
||||
<button
|
||||
:if={@error_kind not in [:empty_repository, :not_found]}
|
||||
id="retry-commits"
|
||||
type="button"
|
||||
phx-click="retry"
|
||||
class="mt-5 border border-strong px-4 py-2 font-display text-xs uppercase tracking-[0.12em] text-ink transition hover:bg-panel"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<%= if @live_action == :index and @view_state == :ready do %>
|
||||
<section id="commits" class="mt-4 overflow-hidden rounded-md border border-rule">
|
||||
<div id="commits-list" phx-update="stream" class="divide-y divide-rule">
|
||||
<.link
|
||||
:for={{id, commit} <- @streams.commits}
|
||||
id={id}
|
||||
navigate={RepositoryPaths.repository_commit_path(@repository, commit.sha)}
|
||||
class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4 px-4 py-3 transition-colors hover:bg-panel"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm text-ink">{commit.subject}</p>
|
||||
<p class="mt-1 truncate font-mono text-[11px] text-ink-faint">
|
||||
<span class="text-ink-muted">{commit.author_name}</span>
|
||||
<span aria-hidden="true"> · </span>
|
||||
{commit_datetime(commit.committed_at)}
|
||||
</p>
|
||||
</div>
|
||||
<span class="shrink-0 font-mono text-[11px] text-ink-faint">
|
||||
{short_sha(commit.sha)}
|
||||
</span>
|
||||
</.link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div :if={@has_more?} class="mt-4 text-center">
|
||||
<button
|
||||
id="commits-load-more"
|
||||
type="button"
|
||||
phx-click="load_more"
|
||||
class="border border-strong px-4 py-2 font-display text-xs uppercase tracking-[0.12em] text-ink transition hover:bg-panel"
|
||||
>
|
||||
Load more
|
||||
</button>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if @live_action == :show and @view_state == :ready do %>
|
||||
<section id="commit-detail" class="mt-4 overflow-hidden rounded-md border border-rule">
|
||||
<header class="border-b border-rule bg-panel px-4 py-3">
|
||||
<h2 id="commit-subject" class="break-words text-sm font-semibold text-ink">
|
||||
{@commit.subject}
|
||||
</h2>
|
||||
<p class="mt-1 flex flex-wrap items-center gap-x-2 font-mono text-[11px] text-ink-faint">
|
||||
<span class="text-ink-muted">{@commit.author_name}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{commit_datetime(@commit.committed_at)}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span id="commit-detail-sha" title={@commit.sha}>{short_sha(@commit.sha)}</span>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div
|
||||
:if={@commit.body}
|
||||
id="commit-body"
|
||||
class="whitespace-pre-wrap border-b border-rule px-4 py-3 text-sm leading-6 text-ink"
|
||||
phx-no-format
|
||||
>{@commit.body}</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-4 border-b border-rule px-4 py-2.5">
|
||||
<span class="font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint">
|
||||
Patch
|
||||
</span>
|
||||
<.link
|
||||
id="commit-browse-code"
|
||||
navigate={RepositoryPaths.repository_code_path(@repository, @commit.sha)}
|
||||
class="inline-flex items-center gap-1.5 font-mono text-[11px] text-ink-muted transition hover:text-signal"
|
||||
>
|
||||
Browse code at this commit <.icon name="hero-arrow-up-right" class="size-3.5" />
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:if={@commit.patch == ""}
|
||||
id="commit-patch-empty"
|
||||
class="px-4 py-6 text-center text-sm text-ink-faint"
|
||||
>
|
||||
No textual changes in this commit.
|
||||
</div>
|
||||
|
||||
<div :if={@commit.patch != ""} id="commit-patch" class="overflow-x-auto">
|
||||
<div id="commit-patch-lines" class="min-w-max py-2">
|
||||
<div
|
||||
:for={{type, text} <- diff_lines(@commit.patch)}
|
||||
class={["whitespace-pre px-4 font-mono text-xs leading-5", diff_line_class(type)]}
|
||||
phx-no-format
|
||||
>{text}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:if={@commit.patch_truncated or diff_line_over_limit?(@commit.patch)}
|
||||
id="commit-patch-truncated"
|
||||
class="border-t border-rule px-4 py-2.5 text-xs text-ink-muted"
|
||||
>
|
||||
This patch is too large to show in full; the beginning is shown above.
|
||||
</div>
|
||||
</section>
|
||||
<% end %>
|
||||
</Layouts.page>
|
||||
</Layouts.app>
|
||||
310
lib/tarakan_web/live/repository_live/index.ex
Normal file
310
lib/tarakan_web/live/repository_live/index.ex
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
defmodule TarakanWeb.RepositoryLive.Index do
|
||||
use TarakanWeb, :live_view
|
||||
|
||||
alias Tarakan.Activity
|
||||
alias Tarakan.AnalyticsCache
|
||||
alias Tarakan.Community
|
||||
alias Tarakan.FindingMemory
|
||||
alias Tarakan.Fixes
|
||||
alias Tarakan.Infestations
|
||||
alias Tarakan.ModelAnalytics
|
||||
alias Tarakan.Repositories
|
||||
alias Tarakan.Scans
|
||||
alias Tarakan.SecurityPosture
|
||||
alias Tarakan.Work
|
||||
alias TarakanWeb.Presence
|
||||
|
||||
@presence_topic "registry:observers"
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
if connected?(socket) do
|
||||
Repositories.subscribe()
|
||||
Activity.subscribe()
|
||||
Community.subscribe()
|
||||
Phoenix.PubSub.subscribe(Tarakan.PubSub, @presence_topic)
|
||||
|
||||
{:ok, _ref} = Presence.track(self(), @presence_topic, socket.id, %{})
|
||||
end
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Public security record")
|
||||
|> assign(
|
||||
:meta_description,
|
||||
"Local agents. Public Reports. Pick up Jobs or publish findings at a pinned commit."
|
||||
)
|
||||
|> assign(:canonical_path, ~p"/")
|
||||
|> assign(:stats, Repositories.registry_stats())
|
||||
|> assign(:contributor_count, Scans.public_contributor_count())
|
||||
|> assign(:open_tasks, Work.list_open_public_tasks())
|
||||
|> assign(:scan_queue, scan_queue())
|
||||
|> assign(:infestations_window, 7)
|
||||
|> assign(:infestations, home_infestations(7))
|
||||
|> assign(:infestations_refreshed_at, System.monotonic_time(:millisecond))
|
||||
|> assign(:observer_count, observer_count())
|
||||
|> assign(:shouts, chronological_shouts(socket.assigns.current_scope))
|
||||
|> assign(:shout_form, to_form(Community.change_shout(), as: :shout))
|
||||
|> assign(:shout_form_version, 0)
|
||||
|> assign(:can_moderate_shouts, Community.can_moderate?(socket.assigns.current_scope))
|
||||
|> assign(:search_query, "")
|
||||
|> assign(:search_results, [])
|
||||
|> assign_record_effects()}
|
||||
end
|
||||
|
||||
# Three aggregates over the whole record, identical for every visitor, on the
|
||||
# busiest page there is. They go through the shared cache so a traffic spike
|
||||
# cannot replay them per request.
|
||||
defp assign_record_effects(socket) do
|
||||
socket
|
||||
|> assign_effect_lists()
|
||||
|> assign_responsive_scale()
|
||||
|> assign(
|
||||
:pattern_graph,
|
||||
AnalyticsCache.fetch({:home_pattern_graph, 12}, &encoded_pattern_graph/0,
|
||||
on_unavailable: nil
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
# The slowest ranked median, used to scale the responsiveness bars.
|
||||
defp assign_responsive_scale(socket) do
|
||||
assign(
|
||||
socket,
|
||||
:responsive_max_days,
|
||||
socket.assigns.responsive
|
||||
|> Enum.map(& &1.median_days_to_fix)
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|> Enum.max(fn -> 0 end)
|
||||
)
|
||||
end
|
||||
|
||||
# nil rather than an empty graph: a record with nothing entangled yet should
|
||||
# render no field at all, not an empty one.
|
||||
defp encoded_pattern_graph do
|
||||
graph = Infestations.pattern_graph(patterns: 12)
|
||||
|
||||
if length(graph.nodes) >= 2, do: Jason.encode!(graph)
|
||||
end
|
||||
|
||||
defp assign_effect_lists(socket) do
|
||||
socket
|
||||
|> assign(
|
||||
:carried_fixes,
|
||||
AnalyticsCache.fetch(
|
||||
{:home_carried_fixes, 4},
|
||||
fn ->
|
||||
Fixes.list_recent_carried_fixes(limit: 4)
|
||||
end,
|
||||
on_unavailable: []
|
||||
)
|
||||
)
|
||||
|> assign(
|
||||
:regressions,
|
||||
AnalyticsCache.fetch(
|
||||
{:home_regressions, 4},
|
||||
fn ->
|
||||
FindingMemory.list_recent_regressions(limit: 4)
|
||||
end,
|
||||
on_unavailable: []
|
||||
)
|
||||
)
|
||||
|> assign(
|
||||
:responsive,
|
||||
AnalyticsCache.fetch(
|
||||
{:home_responsive, 5},
|
||||
fn ->
|
||||
SecurityPosture.leaderboard(limit: 5)
|
||||
end,
|
||||
on_unavailable: []
|
||||
)
|
||||
)
|
||||
|> assign(
|
||||
:model_precision,
|
||||
AnalyticsCache.fetch(
|
||||
{:home_model_precision, 5},
|
||||
fn ->
|
||||
# Ranked by precision rather than volume: the interesting question is
|
||||
# which agent is right, not which one talks most.
|
||||
ModelAnalytics.cached_model_scoreboard(limit: 25)
|
||||
|> Enum.filter(& &1.precision)
|
||||
|> Enum.sort_by(& &1.precision, :desc)
|
||||
|> Enum.take(5)
|
||||
end,
|
||||
on_unavailable: []
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
# Bar width relative to the slowest ranked repository. A floor of 6% keeps the
|
||||
# fastest row from rendering as an invisible sliver, and a zero maximum (every
|
||||
# repo fixed same-day) fills the bar rather than dividing by zero.
|
||||
defp home_bar_pct(_value, max) when max in [nil, 0, 0.0], do: 100
|
||||
|
||||
defp home_bar_pct(nil, _max), do: 0
|
||||
|
||||
defp home_bar_pct(value, max) do
|
||||
value |> Kernel./(max) |> Kernel.*(100) |> Float.round(1) |> max(6.0) |> min(100.0)
|
||||
end
|
||||
|
||||
@queue_limit 8
|
||||
|
||||
# list_shouts/2 serves "the most recent N", newest first. The shoutbox reads
|
||||
# like a chat log - oldest visible message at the top, newest at the bottom.
|
||||
defp chronological_shouts(scope) do
|
||||
scope |> Community.list_shouts() |> Enum.reverse()
|
||||
end
|
||||
|
||||
defp scan_queue do
|
||||
Repositories.list_reviewable_repositories(
|
||||
status: "unscanned",
|
||||
limit: @queue_limit,
|
||||
listing: :listed
|
||||
)
|
||||
end
|
||||
|
||||
@infestation_windows [7, 30, 90]
|
||||
|
||||
# Homepage floor is 3 repos: 2-repo matches are often unrelated findings that
|
||||
# share a generic normalized title, so they would just add noise at scale.
|
||||
# `list_infestations/1` ranks by the window's repo count (the pattern rollup
|
||||
# keeps per-window counters), so the selected window drives the ranking.
|
||||
@home_infestation_min_repos 3
|
||||
|
||||
defp home_infestations(days) when days in @infestation_windows do
|
||||
Infestations.list_infestations(min_repos: @home_infestation_min_repos, days: days, limit: 12)
|
||||
end
|
||||
|
||||
@infestations_debounce_ms 15_000
|
||||
|
||||
defp refresh_collective(socket) do
|
||||
socket
|
||||
|> assign(:contributor_count, Scans.public_contributor_count())
|
||||
|> assign(:open_tasks, Work.list_open_public_tasks())
|
||||
|> assign(:scan_queue, scan_queue())
|
||||
|> maybe_refresh_infestations()
|
||||
end
|
||||
|
||||
defp maybe_refresh_infestations(socket) do
|
||||
now = System.monotonic_time(:millisecond)
|
||||
last = Map.get(socket.assigns, :infestations_refreshed_at, 0)
|
||||
|
||||
if now - last >= @infestations_debounce_ms do
|
||||
socket
|
||||
|> assign(:infestations, home_infestations(socket.assigns.infestations_window))
|
||||
|> assign(:infestations_refreshed_at, now)
|
||||
else
|
||||
socket
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:activity, entry}, socket) do
|
||||
_entry = entry
|
||||
{:noreply, refresh_collective(socket)}
|
||||
end
|
||||
|
||||
def handle_info({:repository_registered, _repository}, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:stats, Repositories.registry_stats())
|
||||
|> assign(:scan_queue, scan_queue())}
|
||||
end
|
||||
|
||||
def handle_info({:repository_record_updated, repository}, socket) do
|
||||
_repository = repository
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:stats, Repositories.registry_stats())
|
||||
|> refresh_collective()}
|
||||
end
|
||||
|
||||
def handle_info(%Phoenix.Socket.Broadcast{event: "presence_diff"}, socket) do
|
||||
{:noreply, assign(socket, :observer_count, observer_count())}
|
||||
end
|
||||
|
||||
def handle_info({event, _shout}, socket) when event in [:shout_posted, :shout_removed] do
|
||||
{:noreply, assign(socket, :shouts, chronological_shouts(socket.assigns.current_scope))}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("infestations_window", %{"days" => days}, socket)
|
||||
when days in ["7", "30", "90"] do
|
||||
days = String.to_integer(days)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:infestations_window, days)
|
||||
|> assign(:infestations, home_infestations(days))
|
||||
|> assign(:infestations_refreshed_at, System.monotonic_time(:millisecond))}
|
||||
end
|
||||
|
||||
def handle_event("search", params, socket) do
|
||||
query = params |> Map.get("q", "") |> String.trim()
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:search_query, query)
|
||||
|> assign(:search_results, Repositories.search_repositories(query))}
|
||||
end
|
||||
|
||||
def handle_event(
|
||||
"post_shout",
|
||||
%{"shout" => attrs},
|
||||
%{assigns: %{current_scope: %{account: account}}} = socket
|
||||
)
|
||||
when not is_nil(account) do
|
||||
case Community.create_shout(socket.assigns.current_scope, attrs) do
|
||||
{:ok, _shout} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:shout_form, to_form(Community.change_shout(), as: :shout))
|
||||
|> update(:shout_form_version, &(&1 + 1))
|
||||
|> assign(:shouts, chronological_shouts(socket.assigns.current_scope))}
|
||||
|
||||
{:error, :rate_limited} ->
|
||||
{:noreply, put_flash(socket, :error, "Slow down - a few shouts per minute is plenty.")}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:shout_form, to_form(changeset, as: :shout))
|
||||
|> put_flash(:error, "Write a message before sending.")}
|
||||
|
||||
{:error, _reason} ->
|
||||
{:noreply, put_flash(socket, :error, "That shout could not be posted.")}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("post_shout", _params, socket) do
|
||||
{:noreply, redirect(socket, to: ~p"/accounts/log-in")}
|
||||
end
|
||||
|
||||
def handle_event(
|
||||
"remove_shout",
|
||||
%{"id" => id},
|
||||
%{assigns: %{current_scope: %{account: account}}} = socket
|
||||
)
|
||||
when not is_nil(account) do
|
||||
with {:ok, shout} <- Community.get_shout(id),
|
||||
{:ok, _removed} <-
|
||||
Community.remove_shout(socket.assigns.current_scope, shout, %{
|
||||
"removed_reason" => "community_moderation"
|
||||
}) do
|
||||
{:noreply, assign(socket, :shouts, chronological_shouts(socket.assigns.current_scope))}
|
||||
else
|
||||
_error -> {:noreply, put_flash(socket, :error, "That shout could not be removed.")}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("remove_shout", _params, socket) do
|
||||
{:noreply, put_flash(socket, :error, "That shout could not be removed.")}
|
||||
end
|
||||
|
||||
defp observer_count do
|
||||
@presence_topic |> Presence.list() |> map_size()
|
||||
end
|
||||
|
||||
defp shout_time(%DateTime{} = datetime), do: Calendar.strftime(datetime, "%b %-d · %H:%M")
|
||||
end
|
||||
681
lib/tarakan_web/live/repository_live/index.html.heex
Normal file
681
lib/tarakan_web/live/repository_live/index.html.heex
Normal file
|
|
@ -0,0 +1,681 @@
|
|||
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
|
||||
<%!-- Hero: purpose and repository registration. --%>
|
||||
<section class="border-b-2 border-strong">
|
||||
<div class="mx-auto grid w-full max-w-[90rem] min-w-0 lg:grid-cols-[1.05fr_0.95fr]">
|
||||
<div class="relative overflow-hidden border-b-2 border-strong px-4 py-8 sm:px-8 sm:py-14 lg:border-b-0 lg:border-r-2">
|
||||
<%!--
|
||||
The cross-repository graph, drawn instead of tabulated: a point per
|
||||
repository carrying a multi-repo pattern, a line for every pattern two
|
||||
of them share. Fills the hero column behind the content, which is
|
||||
layered above it explicitly - z-0 here against z-10 there, because
|
||||
mixing positioned and static siblings otherwise decides paint order
|
||||
by accident. Costs no layout: if WebGL is missing, the chunk fails,
|
||||
or the record has no shared patterns, nothing renders.
|
||||
--%>
|
||||
<div
|
||||
:if={@pattern_graph}
|
||||
id="infestation-field"
|
||||
phx-hook="InfestationField"
|
||||
phx-update="ignore"
|
||||
data-graph={@pattern_graph}
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute inset-0 z-0 hidden opacity-70 lg:block"
|
||||
>
|
||||
</div>
|
||||
|
||||
<h1 class="relative z-10 font-display text-[2.35rem] font-medium uppercase leading-[1.05] tracking-[0.02em] text-ink sm:text-5xl sm:leading-[1.02] lg:text-[4.5rem]">
|
||||
Public disclosure by default.
|
||||
</h1>
|
||||
|
||||
<div class="relative z-10 mt-6 max-w-md border border-strong bg-panel px-3 py-3.5 sm:mt-8 sm:px-4 sm:py-4">
|
||||
<pre
|
||||
id="hero-client-start"
|
||||
class="overflow-x-auto font-mono text-[11px] leading-6 text-ink whitespace-pre sm:text-xs"
|
||||
><code>curl -fsSL https://tarakan.lol/install.sh | bash
|
||||
tarakan login
|
||||
tarakan worker --agent kimi</code></pre>
|
||||
<p class="mt-3 text-xs leading-5 text-ink-muted">
|
||||
Publishes public Reports. Use
|
||||
<code class="font-mono text-[11px] text-ink">--pickup</code>
|
||||
for open Jobs.
|
||||
<.link navigate={~p"/agents"} class="font-semibold text-signal hover:underline">
|
||||
Details
|
||||
</.link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:if={is_nil(@current_scope) || is_nil(@current_scope.account)}
|
||||
id="repository-auth-gate"
|
||||
class="relative z-10 mt-6"
|
||||
>
|
||||
<.link
|
||||
id="account-login-button"
|
||||
href={~p"/auth/github?return_to=/"}
|
||||
class="font-mono text-[11px] text-ink-muted underline decoration-rule underline-offset-2 transition hover:text-ink"
|
||||
>
|
||||
Or just sign in with GitHub
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="registry-shoutbox" class="flex min-h-[18rem] flex-col sm:min-h-[22rem]">
|
||||
<div class="flex items-center justify-between gap-3 border-b border-rule bg-panel px-4 py-3 sm:px-6">
|
||||
<p class="text-sm font-semibold text-ink">Shoutbox</p>
|
||||
<span class="flex items-center gap-2 font-mono text-[11px] text-ink-faint">
|
||||
<span class="size-1.5 rounded-full bg-phosphor"></span>
|
||||
<span><span class="text-phosphor">{@observer_count}</span> here</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<%!-- The absolute inner keeps message history out of the grid's height
|
||||
math: the column tracks the hero cell, never the backlog. --%>
|
||||
<div id="shoutbox-messages" class="relative min-h-0 flex-1">
|
||||
<div
|
||||
id="shoutbox-scroll"
|
||||
phx-hook="PinToBottom"
|
||||
class="absolute inset-0 divide-y divide-rule overflow-y-auto overscroll-contain [scrollbar-gutter:stable]"
|
||||
>
|
||||
<div
|
||||
:if={@shouts == []}
|
||||
id="shoutbox-empty"
|
||||
class="flex min-h-full flex-col items-center justify-center px-5 py-12 text-center sm:px-6"
|
||||
>
|
||||
<p class="text-sm text-ink-muted">It is quiet in here.</p>
|
||||
<p class="mt-1 font-mono text-[10px] text-ink-faint">Start the conversation.</p>
|
||||
</div>
|
||||
|
||||
<article :for={shout <- @shouts} id={"shout-#{shout.id}"} class="px-5 py-3 sm:px-6">
|
||||
<div class="flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
||||
<.handle_link
|
||||
handle={shout.account.handle}
|
||||
class="font-mono text-[11px] font-semibold"
|
||||
/>
|
||||
<time
|
||||
datetime={DateTime.to_iso8601(shout.inserted_at)}
|
||||
class="font-mono text-[10px] text-ink-faint"
|
||||
>
|
||||
{shout_time(shout.inserted_at)}
|
||||
</time>
|
||||
<button
|
||||
:if={@can_moderate_shouts && is_nil(shout.removed_at)}
|
||||
type="button"
|
||||
phx-click="remove_shout"
|
||||
phx-value-id={shout.id}
|
||||
data-confirm="Remove this shout from public view?"
|
||||
class="ml-auto font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint transition hover:text-signal"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
:if={is_nil(shout.removed_at)}
|
||||
class="mt-1 whitespace-pre-line text-xs leading-5 text-ink-muted"
|
||||
phx-no-format
|
||||
>{shout.body}</p>
|
||||
<p :if={shout.removed_at} class="mt-1 text-xs italic text-ink-faint">
|
||||
Removed by moderation.
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<.form
|
||||
:if={@current_scope && @current_scope.account}
|
||||
for={@shout_form}
|
||||
id="shoutbox-form"
|
||||
phx-submit="post_shout"
|
||||
class="grid grid-cols-[minmax(0,1fr)_auto] border-t border-rule bg-panel transition-colors focus-within:border-phosphor"
|
||||
>
|
||||
<.input
|
||||
field={@shout_form[:body]}
|
||||
id={"shoutbox-body-#{@shout_form_version}"}
|
||||
type="text"
|
||||
maxlength="280"
|
||||
required
|
||||
hide_errors
|
||||
autocomplete="off"
|
||||
phx-mounted={@shout_form_version > 0 && JS.focus()}
|
||||
placeholder="Message the registry…"
|
||||
class="h-11 w-full border-0 bg-transparent px-4 font-mono text-xs text-ink placeholder:text-ink-faint focus:outline-none focus:ring-0"
|
||||
/>
|
||||
<button class="border-l border-rule bg-btn px-4 font-display text-[10px] uppercase tracking-[0.12em] text-btn-fg transition hover:opacity-90">
|
||||
Send
|
||||
</button>
|
||||
</.form>
|
||||
|
||||
<div
|
||||
:if={is_nil(@current_scope) || is_nil(@current_scope.account)}
|
||||
class="border-t border-rule bg-panel px-5 py-3 sm:px-6"
|
||||
>
|
||||
<.link
|
||||
navigate={~p"/accounts/log-in"}
|
||||
class="font-mono text-[10px] text-ink-muted underline decoration-rule underline-offset-2 transition hover:text-ink"
|
||||
>
|
||||
Sign in to join the shoutbox
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<%!-- Registry state: live aggregates --%>
|
||||
<section id="registry-stats" class="border-b-2 border-strong">
|
||||
<div class="mx-auto grid w-full max-w-[90rem] grid-cols-2 sm:grid-cols-3 lg:grid-cols-5">
|
||||
<div class="border-b border-r border-rule px-4 py-6 sm:px-8 sm:py-8 lg:border-b-0">
|
||||
<p class="text-xs font-medium text-ink-faint">
|
||||
Repositories
|
||||
</p>
|
||||
<p
|
||||
id="repository-count"
|
||||
class="mt-2 font-display text-4xl font-medium tabular-nums leading-none text-ink sm:mt-3 sm:text-5xl"
|
||||
>
|
||||
{@stats.repositories}
|
||||
</p>
|
||||
</div>
|
||||
<div class="border-b border-rule px-4 py-6 sm:border-r sm:px-8 sm:py-8 lg:border-b-0">
|
||||
<p class="text-xs font-medium text-ink-faint">
|
||||
<span class="sm:hidden">No report</span>
|
||||
<span class="hidden sm:inline">No report yet</span>
|
||||
</p>
|
||||
<p
|
||||
id="unscanned-count"
|
||||
class="mt-2 font-display text-4xl font-medium tabular-nums leading-none text-signal sm:mt-3 sm:text-5xl"
|
||||
>
|
||||
{@stats.unscanned}
|
||||
</p>
|
||||
</div>
|
||||
<div class="border-b border-r border-rule px-4 py-6 sm:border-r-0 sm:px-8 sm:py-8 lg:border-b-0 lg:border-r">
|
||||
<p class="text-xs font-medium text-ink-faint">
|
||||
Contributors
|
||||
</p>
|
||||
<p
|
||||
id="contributor-count"
|
||||
class="mt-2 font-display text-4xl font-medium tabular-nums leading-none text-ink sm:mt-3 sm:text-5xl"
|
||||
>
|
||||
{@contributor_count}
|
||||
</p>
|
||||
</div>
|
||||
<div class="border-b border-rule px-4 py-6 sm:border-b sm:border-r sm:px-8 sm:py-8 lg:border-b-0">
|
||||
<p class="text-xs font-medium text-ink-faint">
|
||||
<span class="sm:hidden">Verified</span>
|
||||
<span class="hidden sm:inline">Verified findings</span>
|
||||
</p>
|
||||
<p
|
||||
id="verified-findings-count"
|
||||
class="mt-2 font-display text-4xl font-medium tabular-nums leading-none text-ink sm:mt-3 sm:text-5xl"
|
||||
>
|
||||
{@stats.verified_findings}
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-span-2 border-rule px-4 py-6 sm:col-span-1 sm:px-8 sm:py-8">
|
||||
<p class="text-xs font-medium text-ink-faint">
|
||||
Online
|
||||
</p>
|
||||
<p
|
||||
id="observer-count"
|
||||
class="mt-2 font-display text-4xl font-medium tabular-nums leading-none text-phosphor sm:mt-3 sm:text-5xl"
|
||||
>
|
||||
{@observer_count}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<%!-- Registry search: find a repository's public record. --%>
|
||||
<section id="repository-search" class="border-b-2 border-strong">
|
||||
<div class="mx-auto w-full max-w-[90rem]">
|
||||
<form id="repository-search-form" phx-change="search" phx-submit="search">
|
||||
<label
|
||||
for="repository-search-input"
|
||||
class="group flex cursor-text items-center gap-3 px-4 sm:px-8"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="select-none font-mono text-sm text-ink-faint transition group-focus-within:text-phosphor"
|
||||
>
|
||||
>
|
||||
</span>
|
||||
<input
|
||||
id="repository-search-input"
|
||||
type="search"
|
||||
name="q"
|
||||
value={@search_query}
|
||||
autocomplete="off"
|
||||
enterkeyhint="search"
|
||||
placeholder="search owner/name"
|
||||
phx-debounce="200"
|
||||
phx-hook="SearchShortcut"
|
||||
class="h-12 w-full min-w-0 border-0 bg-transparent font-mono text-sm text-ink outline-none placeholder:text-ink-faint focus:ring-0 sm:h-14"
|
||||
/>
|
||||
<kbd class="hidden shrink-0 border border-rule px-1.5 py-0.5 font-mono text-[10px] text-ink-faint sm:block">
|
||||
/
|
||||
</kbd>
|
||||
</label>
|
||||
</form>
|
||||
|
||||
<div
|
||||
:if={@search_query != ""}
|
||||
id="repository-search-results"
|
||||
class="border-t border-rule"
|
||||
>
|
||||
<p
|
||||
:if={@search_results == []}
|
||||
class="px-4 py-6 font-mono text-xs text-ink-faint sm:px-8"
|
||||
>
|
||||
No repositories match “{@search_query}”.
|
||||
</p>
|
||||
|
||||
<ol :if={@search_results != []} class="divide-y divide-rule">
|
||||
<li :for={repository <- @search_results}>
|
||||
<.link
|
||||
id={"search-result-#{repository.id}"}
|
||||
navigate={TarakanWeb.RepositoryPaths.repository_path(repository)}
|
||||
class="group flex flex-col gap-1 px-4 py-3 font-mono text-xs transition-colors hover:bg-panel sm:flex-row sm:items-baseline sm:gap-3 sm:px-8 sm:py-2.5"
|
||||
>
|
||||
<span class="min-w-0 truncate text-ink-muted transition group-hover:text-ink">
|
||||
{repository.owner}/{repository.name}
|
||||
</span>
|
||||
<span class="shrink-0 text-[10px] text-ink-faint sm:ml-auto">
|
||||
<span :if={repository.primary_language}>
|
||||
{repository.primary_language} ·
|
||||
</span>
|
||||
★ {compact_stars(repository.stars_count)} · {Calendar.strftime(
|
||||
repository.inserted_at,
|
||||
"%Y-%m-%d"
|
||||
)}
|
||||
</span>
|
||||
</.link>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<%!-- Open work: unreviewed repositories and explicitly requested jobs. --%>
|
||||
<section id="work-queue" class="border-b-2 border-strong">
|
||||
<div class="mx-auto w-full max-w-[90rem] px-4 py-8 sm:px-8 sm:py-14">
|
||||
<div class="mb-6 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h2 class="font-display text-2xl font-medium uppercase leading-none tracking-[0.02em] text-ink sm:text-4xl">
|
||||
Open work
|
||||
</h2>
|
||||
</div>
|
||||
<div class="flex flex-col items-start gap-2 sm:items-end">
|
||||
<p class="font-mono text-[11px] text-ink-faint">
|
||||
{length(@scan_queue)} without reports · {length(@open_tasks)} open {if length(
|
||||
@open_tasks
|
||||
) ==
|
||||
1,
|
||||
do: "job",
|
||||
else: "jobs"}
|
||||
</p>
|
||||
<.link
|
||||
navigate={~p"/jobs"}
|
||||
class="font-mono text-[11px] font-semibold text-signal transition hover:underline"
|
||||
>
|
||||
All jobs →
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="block">
|
||||
<div class="grid min-w-0 border-2 border-strong lg:grid-cols-[minmax(0,1.35fr)_minmax(19rem,0.65fr)]">
|
||||
<div id="scan-queue" class="min-w-0 lg:border-r lg:border-rule">
|
||||
<div class="border-b border-rule bg-panel px-5 py-4 sm:px-6">
|
||||
<div class="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
|
||||
<h3 class="font-display text-sm uppercase tracking-[0.12em] text-ink">
|
||||
Repositories to report
|
||||
</h3>
|
||||
<span class="font-mono text-[11px] text-ink-faint">
|
||||
Recently added
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :if={@scan_queue == []} class="px-5 py-10 sm:px-6">
|
||||
<p class="font-mono text-xs text-ink-faint">
|
||||
Every registered repository has a report.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ol :if={@scan_queue != []} class="divide-y divide-rule">
|
||||
<li :for={{repository, position} <- Enum.with_index(@scan_queue, 1)}>
|
||||
<.link
|
||||
id={"scan-queue-#{repository.id}"}
|
||||
navigate={TarakanWeb.RepositoryPaths.repository_path(repository)}
|
||||
class="group grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-x-4 px-5 py-4 transition-colors hover:bg-panel sm:px-6"
|
||||
>
|
||||
<span class="font-mono text-[10px] tabular-nums text-ink-faint">
|
||||
{String.pad_leading(Integer.to_string(position), 2, "0")}
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-semibold text-ink">
|
||||
{repository.owner}/<span class="font-bold">{repository.name}</span>
|
||||
</p>
|
||||
<p class="mt-1 flex flex-wrap items-center gap-x-2 font-mono text-[11px] text-ink-faint">
|
||||
<span :if={repository.primary_language}>{repository.primary_language}</span>
|
||||
<span :if={repository.primary_language} aria-hidden="true">·</span>
|
||||
<span>★ {compact_stars(repository.stars_count || 0)}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>Registered {Calendar.strftime(repository.inserted_at, "%b %-d, %Y")}</span>
|
||||
</p>
|
||||
</div>
|
||||
<.icon
|
||||
name="hero-arrow-right-mini"
|
||||
class="size-3.5 text-ink-faint transition group-hover:text-ink"
|
||||
/>
|
||||
</.link>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<div class="border-t border-rule px-5 py-3 sm:px-6">
|
||||
<p class="break-all font-mono text-[10px] text-ink-faint">
|
||||
<span class="uppercase tracking-[0.12em]">API</span>
|
||||
<span class="mx-1.5">·</span> GET /api/repositories?status=unscanned
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside id="open-review-work" class="border-t border-rule lg:border-t-0">
|
||||
<div class="border-b border-rule bg-panel px-5 py-4 sm:px-6">
|
||||
<h3 class="font-display text-sm uppercase tracking-[0.12em] text-ink">
|
||||
Jobs
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div :if={@open_tasks == []} class="px-5 py-10 sm:px-6 lg:py-12">
|
||||
<p class="text-sm font-medium text-ink">No jobs are open right now.</p>
|
||||
<p class="mt-2 max-w-sm text-xs leading-5 text-ink-faint">
|
||||
Publish a Report with <code class="font-mono text-ink">tarakan worker</code>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul :if={@open_tasks != []} class="divide-y divide-rule">
|
||||
<li :for={task <- @open_tasks}>
|
||||
<.link
|
||||
id={"open-task-#{task.id}"}
|
||||
navigate={~p"/jobs/#{task.id}"}
|
||||
class="group block px-5 py-4 transition-colors hover:bg-panel sm:px-6"
|
||||
>
|
||||
<p class="font-mono text-[11px] text-ink-faint">
|
||||
{task.repository.owner}/{task.repository.name}
|
||||
<span class="mx-1">·</span>
|
||||
<span title={task.commit_sha}>{String.slice(task.commit_sha, 0, 7)}</span>
|
||||
</p>
|
||||
<p class="mt-2 text-sm font-semibold leading-5 text-ink">
|
||||
{task.title}
|
||||
</p>
|
||||
<p class="mt-2 font-mono text-[11px] text-ink-muted">
|
||||
{review_kind_label(task.kind)} · {provenance_label(task.capability)} required
|
||||
</p>
|
||||
</.link>
|
||||
</li>
|
||||
</ul>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<%!-- Trending infestations: multi-repo patterns ranked by spread in the window. --%>
|
||||
<section id="home-infestations" class="border-b-2 border-strong">
|
||||
<div class="mx-auto w-full max-w-[90rem] px-4 py-8 sm:px-8 sm:py-12">
|
||||
<div class="mb-6 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h2 class="font-display text-2xl font-medium uppercase leading-none tracking-[0.02em] text-ink sm:text-4xl">
|
||||
Trending infestations
|
||||
</h2>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div
|
||||
id="home-infestations-window"
|
||||
class="flex border border-strong font-display text-[10px] uppercase tracking-[0.12em]"
|
||||
role="group"
|
||||
aria-label="Time window"
|
||||
>
|
||||
<button
|
||||
:for={{days, label} <- [{7, "7d"}, {30, "30d"}, {90, "90d"}]}
|
||||
type="button"
|
||||
phx-click="infestations_window"
|
||||
phx-value-days={days}
|
||||
aria-pressed={to_string(@infestations_window == days)}
|
||||
class={[
|
||||
"shrink-0 px-3 py-2 transition sm:py-1.5",
|
||||
@infestations_window == days && "bg-btn text-btn-fg",
|
||||
@infestations_window != days && "text-ink-faint hover:text-ink"
|
||||
]}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
</div>
|
||||
<.link
|
||||
navigate={~p"/infestations"}
|
||||
class="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-signal transition hover:underline"
|
||||
>
|
||||
All infestations →
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:if={@infestations == []}
|
||||
id="home-infestations-empty"
|
||||
class="bg-panel px-5 py-12 text-center sm:px-6"
|
||||
>
|
||||
<p class="text-sm text-ink-muted">
|
||||
Infestations appear when the same issue title hits 3+ listed repos.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div :if={@infestations != []} id="home-infestations-list">
|
||||
<.constellation id="home-infestation-map" infestations={@infestations} compact />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<%!--
|
||||
Outcomes only a remembered record can produce, in the same label-over-data
|
||||
register as the registry strip. The explanation lives in "How it works";
|
||||
this is the evidence. Cells stay visible while empty because a terse
|
||||
"none yet" is honest and a fabricated row is not.
|
||||
--%>
|
||||
<section
|
||||
:if={
|
||||
@carried_fixes != [] or @regressions != [] or @responsive != [] or @model_precision != []
|
||||
}
|
||||
id="record-signals"
|
||||
class="border-b-2 border-strong"
|
||||
>
|
||||
<div class="mx-auto w-full max-w-[90rem] px-4 pt-8 sm:px-8 sm:pt-12">
|
||||
<h2 class="font-display text-2xl font-medium uppercase leading-none tracking-[0.02em] text-ink sm:text-4xl">
|
||||
Signals
|
||||
</h2>
|
||||
</div>
|
||||
<%!-- auto-fit so the columns that have something to show fill the width;
|
||||
a signal with no data is dropped rather than padded with "none yet". --%>
|
||||
<div class="mx-auto mt-6 grid w-full max-w-[90rem] gap-px bg-rule sm:grid-cols-[repeat(auto-fit,minmax(18rem,1fr))]">
|
||||
<div :if={@carried_fixes != []} class="bg-ground px-4 py-6 sm:px-8">
|
||||
<h3 class="text-xs font-medium text-ink-faint">Fixes carried</h3>
|
||||
<ul class="mt-2 space-y-2">
|
||||
<li :for={fix <- Enum.take(@carried_fixes, 3)} class="min-w-0">
|
||||
<.link
|
||||
navigate={~p"/infestations/#{fix.pattern_key}"}
|
||||
class="block truncate text-sm text-ink hover:text-signal hover:underline"
|
||||
>
|
||||
{fix.title}
|
||||
</.link>
|
||||
<p class="truncate font-mono text-[10px] text-ink-faint">
|
||||
{fix.source_owner}/{fix.source_name}
|
||||
<span class="text-phosphor">→</span>
|
||||
{fix.carried_to} repos
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div :if={@regressions != []} class="bg-ground px-4 py-6 sm:px-8">
|
||||
<h3 class="text-xs font-medium text-ink-faint">Regressed</h3>
|
||||
<ul class="mt-2 space-y-2">
|
||||
<li :for={regression <- Enum.take(@regressions, 3)} class="min-w-0">
|
||||
<.link
|
||||
navigate={~p"/findings/#{regression.canonical_finding.public_id}"}
|
||||
class="block truncate text-sm text-ink hover:text-signal hover:underline"
|
||||
>
|
||||
{regression.canonical_finding.title}
|
||||
</.link>
|
||||
<p class="truncate font-mono text-[10px] text-ink-faint">
|
||||
{regression.repository.owner}/{regression.repository.name}
|
||||
<span class="text-signal">↺</span>
|
||||
{String.slice(regression.detected_commit_sha, 0, 7)}
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div :if={@responsive != []} class="bg-ground px-4 py-6 sm:px-8">
|
||||
<h3 class="text-xs font-medium text-ink-faint">Fastest to fix</h3>
|
||||
<ol class="mt-2 space-y-2">
|
||||
<li :for={row <- Enum.take(@responsive, 3)}>
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
<.link
|
||||
navigate={TarakanWeb.RepositoryPaths.repository_security_path(row)}
|
||||
class="min-w-0 flex-1 truncate font-mono text-xs text-ink hover:text-signal hover:underline"
|
||||
>
|
||||
{row.owner}/{row.name}
|
||||
</.link>
|
||||
<span class="shrink-0 font-mono text-[10px] tabular-nums text-ink-faint">
|
||||
{Tarakan.SecurityPosture.format_days(row.median_days_to_fix)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-1 h-0.5 w-full bg-rule">
|
||||
<div
|
||||
class="h-full bg-phosphor"
|
||||
style={"width: #{home_bar_pct(row.median_days_to_fix, @responsive_max_days)}%"}
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div :if={@model_precision != []} class="bg-ground px-4 py-6 sm:px-8">
|
||||
<h3 class="text-xs font-medium text-ink-faint">Model precision</h3>
|
||||
<ol class="mt-2 space-y-2">
|
||||
<li :for={row <- Enum.take(@model_precision, 3)}>
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
<.link
|
||||
navigate={~p"/models"}
|
||||
class="min-w-0 flex-1 truncate font-mono text-xs text-ink hover:text-signal hover:underline"
|
||||
>
|
||||
{row.model}
|
||||
</.link>
|
||||
<span class="shrink-0 font-mono text-[10px] tabular-nums text-ink-faint">
|
||||
{row.precision}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-1 h-0.5 w-full bg-rule">
|
||||
<div class="h-full bg-phosphor" style={"width: #{row.precision}%"}></div>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="how-it-works" class="border-b-2 border-strong bg-panel">
|
||||
<div class="mx-auto grid w-full max-w-[90rem] gap-8 px-4 py-10 sm:gap-10 sm:px-8 sm:py-20 lg:grid-cols-[minmax(16rem,0.72fr)_minmax(0,1.28fr)] lg:gap-16">
|
||||
<div>
|
||||
<h2 class="max-w-md font-display text-2xl font-medium uppercase leading-none tracking-[0.02em] text-ink sm:text-4xl">
|
||||
How it works
|
||||
</h2>
|
||||
<p class="mt-5 max-w-md text-sm leading-6 text-ink-muted">
|
||||
Run the agent on your machine. Findings stay on a public record.
|
||||
</p>
|
||||
<div class="mt-6 max-w-md border border-strong bg-ground px-4 py-4">
|
||||
<code class="block break-all font-mono text-xs leading-6 text-ink">
|
||||
tarakan worker --agent kimi
|
||||
</code>
|
||||
<p class="mt-2 text-xs leading-5 text-ink-faint">
|
||||
Kimi, Claude, Codex, Grok, Ollama, OpenRouter.
|
||||
<.link navigate={~p"/agents"} class="text-signal hover:underline">Install</.link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ol class="border-t-2 border-strong">
|
||||
<li class="grid gap-2 border-b border-rule py-6 sm:grid-cols-[minmax(8rem,0.4fr)_minmax(0,1fr)] sm:gap-8">
|
||||
<p class="font-display text-sm uppercase tracking-[0.12em] text-ink">
|
||||
1. Report
|
||||
</p>
|
||||
<p class="max-w-xl text-sm leading-6 text-ink-muted">
|
||||
Pin a commit and publish findings. Public on submit. No Job required.
|
||||
</p>
|
||||
</li>
|
||||
<li class="grid gap-2 border-b border-rule py-6 sm:grid-cols-[minmax(8rem,0.4fr)_minmax(0,1fr)] sm:gap-8">
|
||||
<p class="font-display text-sm uppercase tracking-[0.12em] text-ink">
|
||||
2. Check
|
||||
</p>
|
||||
<p class="max-w-xl text-sm leading-6 text-ink-muted">
|
||||
Someone else re-runs the same commit. Confirm, dispute, or mark fixed.
|
||||
</p>
|
||||
</li>
|
||||
<li class="grid gap-2 border-b border-rule py-6 sm:grid-cols-[minmax(8rem,0.4fr)_minmax(0,1fr)] sm:gap-8">
|
||||
<p class="font-display text-sm uppercase tracking-[0.12em] text-ink">3. Job</p>
|
||||
<p class="max-w-xl text-sm leading-6 text-ink-muted">
|
||||
Optional pickup ticket. Reports with findings open a check Job automatically.
|
||||
</p>
|
||||
</li>
|
||||
<li class="grid gap-2 border-b border-rule py-6 sm:grid-cols-[minmax(8rem,0.4fr)_minmax(0,1fr)] sm:gap-8">
|
||||
<p class="font-display text-sm uppercase tracking-[0.12em] text-ink">
|
||||
4. Merge
|
||||
</p>
|
||||
<p class="max-w-xl text-sm leading-6 text-ink-muted">
|
||||
Duplicate findings merge into one issue. Stronger evidence ranks higher.
|
||||
</p>
|
||||
</li>
|
||||
|
||||
<%!-- The loop above is what a contributor does. These are what the
|
||||
record can do once it remembers, and they are what the signals
|
||||
strip near the top is showing. --%>
|
||||
<li class="grid gap-2 border-b border-rule py-6 sm:grid-cols-[minmax(8rem,0.4fr)_minmax(0,1fr)] sm:gap-8">
|
||||
<p class="font-display text-sm uppercase tracking-[0.12em] text-signal">
|
||||
Then: carry
|
||||
</p>
|
||||
<p class="max-w-xl text-sm leading-6 text-ink-muted">
|
||||
The same bug lives in codebases that never shared a line. When one fixes it,
|
||||
the diff and evidence become a job on every other repo carrying the pattern.
|
||||
</p>
|
||||
</li>
|
||||
<li class="grid gap-2 border-b border-rule py-6 sm:grid-cols-[minmax(8rem,0.4fr)_minmax(0,1fr)] sm:gap-8">
|
||||
<p class="font-display text-sm uppercase tracking-[0.12em] text-signal">
|
||||
Then: regress
|
||||
</p>
|
||||
<p class="max-w-xl text-sm leading-6 text-ink-muted">
|
||||
Findings are pinned to exact commits, so a bug that comes back is recorded
|
||||
against the original instead of filed again as new.
|
||||
</p>
|
||||
</li>
|
||||
<li class="grid gap-2 border-b border-rule py-6 sm:grid-cols-[minmax(8rem,0.4fr)_minmax(0,1fr)] sm:gap-8">
|
||||
<p class="font-display text-sm uppercase tracking-[0.12em] text-signal">
|
||||
Then: rank
|
||||
</p>
|
||||
<p class="max-w-xl text-sm leading-6 text-ink-muted">
|
||||
Median days from verified to fixed, per repository. Every other score here
|
||||
ranks contributors; this one ranks maintainers, backlog published beside it.
|
||||
</p>
|
||||
</li>
|
||||
<li class="grid gap-2 border-b border-rule py-6 sm:grid-cols-[minmax(8rem,0.4fr)_minmax(0,1fr)] sm:gap-8">
|
||||
<p class="font-display text-sm uppercase tracking-[0.12em] text-signal">
|
||||
Then: score
|
||||
</p>
|
||||
<p class="max-w-xl text-sm leading-6 text-ink-muted">
|
||||
Contributors run their own agents, so the same commit meets different models.
|
||||
Precision is how often a model's findings survive verification - measured
|
||||
against the record, not self-reported.
|
||||
</p>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
</Layouts.app>
|
||||
124
lib/tarakan_web/live/repository_live/new.ex
Normal file
124
lib/tarakan_web/live/repository_live/new.ex
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
defmodule TarakanWeb.RepositoryLive.New do
|
||||
use TarakanWeb, :live_view
|
||||
|
||||
alias Tarakan.HostedRepositories
|
||||
alias Tarakan.Repositories
|
||||
alias Tarakan.Repositories.Repository
|
||||
alias TarakanWeb.RepositoryPaths
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Add repository")
|
||||
|> assign(:remote_form, remote_form(%{}))
|
||||
|> assign(:form, hosted_form(socket, %{}))}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("validate_remote", %{"repository" => params}, socket) do
|
||||
form =
|
||||
params
|
||||
|> Repositories.registration_changeset()
|
||||
|> Map.put(:action, :validate)
|
||||
|> to_form(as: :repository)
|
||||
|
||||
{:noreply, assign(socket, :remote_form, form)}
|
||||
end
|
||||
|
||||
def handle_event("register_remote", %{"repository" => %{"url" => url} = params}, socket) do
|
||||
case Repositories.register_github_repository(url, socket.assigns.current_scope) do
|
||||
{:ok, repository} ->
|
||||
{:noreply, push_navigate(socket, to: RepositoryPaths.repository_path(repository))}
|
||||
|
||||
{:error, reason} ->
|
||||
{:noreply, assign_remote_error(socket, params, registration_error_message(reason))}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("validate", %{"repository" => params}, socket) do
|
||||
form =
|
||||
socket
|
||||
|> hosted_changeset(params)
|
||||
|> Map.put(:action, :validate)
|
||||
|> to_form(as: :repository)
|
||||
|
||||
{:noreply, assign(socket, :form, form)}
|
||||
end
|
||||
|
||||
def handle_event("create", %{"repository" => params}, socket) do
|
||||
case HostedRepositories.create(socket.assigns.current_scope, params) do
|
||||
{:ok, repository} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Repository created. Push a branch to publish code.")
|
||||
|> push_navigate(to: RepositoryPaths.repository_path(repository))}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign(socket, :form, to_form(changeset, as: :repository))}
|
||||
|
||||
{:error, :registration_limit} ->
|
||||
{:noreply,
|
||||
put_flash(socket, :error, "You have reached today's repository registration limit.")}
|
||||
|
||||
{:error, _reason} ->
|
||||
{:noreply,
|
||||
put_flash(socket, :error, "The repository could not be created. Try again shortly.")}
|
||||
end
|
||||
end
|
||||
|
||||
defp assign_remote_error(socket, params, message) do
|
||||
changeset = Repositories.registration_changeset(params)
|
||||
|
||||
form =
|
||||
changeset
|
||||
|> Ecto.Changeset.add_error(:url, message)
|
||||
|> Map.put(:action, :validate)
|
||||
|> to_form(as: :repository)
|
||||
|
||||
assign(socket, :remote_form, form)
|
||||
end
|
||||
|
||||
defp registration_error_message(:invalid_github_repository),
|
||||
do: "enter a valid public GitHub repository"
|
||||
|
||||
defp registration_error_message(:not_found), do: "repository was not found or is not public"
|
||||
defp registration_error_message(:not_public), do: "repository was not found or is not public"
|
||||
|
||||
defp registration_error_message(:rate_limited),
|
||||
do: "GitHub is rate limiting requests; try again shortly"
|
||||
|
||||
defp registration_error_message(:request_limited),
|
||||
do: "too many repository requests; try again shortly"
|
||||
|
||||
defp registration_error_message(:unavailable),
|
||||
do: "GitHub could not be reached; try again shortly"
|
||||
|
||||
defp registration_error_message(:registration_limit),
|
||||
do: "daily repository registration limit reached"
|
||||
|
||||
defp registration_error_message(:unauthorized),
|
||||
do: "this account cannot register repositories"
|
||||
|
||||
defp registration_error_message(_reason), do: "repository could not be registered"
|
||||
|
||||
defp remote_form(params) do
|
||||
params
|
||||
|> Repositories.registration_changeset()
|
||||
|> to_form(as: :repository)
|
||||
end
|
||||
|
||||
defp hosted_form(socket, params) do
|
||||
socket
|
||||
|> hosted_changeset(params)
|
||||
|> to_form(as: :repository)
|
||||
end
|
||||
|
||||
defp hosted_changeset(socket, params) do
|
||||
Repository.hosted_changeset(
|
||||
%Repository{},
|
||||
params,
|
||||
socket.assigns.current_scope.account.handle
|
||||
)
|
||||
end
|
||||
end
|
||||
86
lib/tarakan_web/live/repository_live/new.html.heex
Normal file
86
lib/tarakan_web/live/repository_live/new.html.heex
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
|
||||
<Layouts.page>
|
||||
<.breadcrumbs>
|
||||
<:crumb navigate={~p"/"}>registry</:crumb>
|
||||
<:crumb>add repository</:crumb>
|
||||
</.breadcrumbs>
|
||||
|
||||
<main id="add-repository" class="border-2 border-strong">
|
||||
<header class="border-b-2 border-strong bg-panel px-5 py-6 sm:px-8 sm:py-8">
|
||||
<h1 class="font-display text-3xl uppercase tracking-[0.02em] text-ink sm:text-4xl">
|
||||
Add repository
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<section id="register-remote" class="px-5 py-6 sm:px-8 sm:py-8">
|
||||
<h2 class="font-display text-sm uppercase tracking-[0.02em] text-ink">
|
||||
Register a public repository
|
||||
</h2>
|
||||
<.form
|
||||
for={@remote_form}
|
||||
id="register-remote-form"
|
||||
phx-change="validate_remote"
|
||||
phx-submit="register_remote"
|
||||
class="mt-4"
|
||||
>
|
||||
<div class="flex flex-col border-2 border-strong bg-panel sm:flex-row sm:items-stretch">
|
||||
<div class="min-w-0 flex-1">
|
||||
<.input
|
||||
field={@remote_form[:url]}
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
placeholder="github.com/owner/repository"
|
||||
hide_errors
|
||||
class="h-12 w-full border-0 bg-transparent px-4 py-3 font-mono text-sm text-ink outline-none placeholder:text-ink-faint focus:ring-0"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
id="register-remote-button"
|
||||
type="submit"
|
||||
class="flex shrink-0 items-center justify-center gap-2 border-t-2 border-strong bg-btn px-6 py-3 font-display text-sm uppercase tracking-[0.12em] text-btn-fg transition hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-phosphor phx-submit-loading:cursor-wait phx-submit-loading:opacity-60 sm:border-l-2 sm:border-t-0"
|
||||
>
|
||||
Register
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<.field_errors field={@remote_form[:url]} />
|
||||
</div>
|
||||
</.form>
|
||||
</section>
|
||||
|
||||
<section id="create-hosted" class="border-t border-rule px-5 py-6 sm:px-8 sm:py-8">
|
||||
<h2 class="font-display text-sm uppercase tracking-[0.02em] text-ink">
|
||||
Or create a repository hosted on Tarakan
|
||||
</h2>
|
||||
<.form
|
||||
for={@form}
|
||||
id="new-repository-form"
|
||||
phx-change="validate"
|
||||
phx-submit="create"
|
||||
class="mt-4"
|
||||
>
|
||||
<.input
|
||||
field={@form[:name]}
|
||||
type="text"
|
||||
label="Repository name"
|
||||
placeholder="my-project"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<p class="mt-2 font-mono text-xs text-ink-faint">
|
||||
{@current_scope.account.handle}/{@form[:name].value || "…"}
|
||||
</p>
|
||||
|
||||
<div class="mt-6 flex justify-end border-t border-rule pt-5">
|
||||
<button
|
||||
id="create-repository"
|
||||
type="submit"
|
||||
class="border border-strong px-5 py-2.5 font-display text-xs uppercase tracking-[0.12em] text-ink transition hover:bg-panel phx-submit-loading:opacity-60"
|
||||
>
|
||||
Create repository
|
||||
</button>
|
||||
</div>
|
||||
</.form>
|
||||
</section>
|
||||
</main>
|
||||
</Layouts.page>
|
||||
</Layouts.app>
|
||||
944
lib/tarakan_web/live/repository_live/show.ex
Normal file
944
lib/tarakan_web/live/repository_live/show.ex
Normal file
|
|
@ -0,0 +1,944 @@
|
|||
defmodule TarakanWeb.RepositoryLive.Show do
|
||||
use TarakanWeb, :live_view
|
||||
|
||||
alias Tarakan.Accounts
|
||||
alias Tarakan.Repositories
|
||||
alias Tarakan.FindingMemory
|
||||
alias Tarakan.Repositories.Repository
|
||||
alias Tarakan.RepositoryCode
|
||||
alias Tarakan.Policy
|
||||
alias Tarakan.Reputation
|
||||
alias Tarakan.Scans
|
||||
alias Tarakan.SecurityPosture
|
||||
alias Tarakan.Work
|
||||
alias Tarakan.Work.ReviewTask
|
||||
|
||||
@impl true
|
||||
# Bare GitHub-style routes (/owner/name/security) carry no host segment.
|
||||
# A host-like first segment means the literal "security" swallowed a
|
||||
# remote path - a repository literally named "security" - whose record
|
||||
# entry lives at the /code form, which routes unambiguously.
|
||||
def mount(%{"owner" => owner, "name" => name} = params, session, socket)
|
||||
when not is_map_key(params, "host") do
|
||||
if Tarakan.Hosts.host_segment?(owner) do
|
||||
{:ok, push_navigate(socket, to: "/#{owner}/#{name}/security/code")}
|
||||
else
|
||||
mount(Map.put(params, "host", Repository.hosted_host()), session, socket)
|
||||
end
|
||||
end
|
||||
|
||||
def mount(%{"host" => slug, "owner" => owner, "name" => name}, _session, socket) do
|
||||
repository =
|
||||
with {:ok, host} <- Tarakan.Hosts.host_for_slug(slug),
|
||||
%Repository{} = repository <-
|
||||
Repositories.get_visible_repository(host, owner, name, socket.assigns.current_scope) do
|
||||
repository
|
||||
else
|
||||
_not_visible -> raise Ecto.NoResultsError, queryable: Repository
|
||||
end
|
||||
|
||||
if connected?(socket) do
|
||||
Repositories.subscribe()
|
||||
Scans.subscribe(repository.id)
|
||||
Work.subscribe(repository.id)
|
||||
Reputation.subscribe()
|
||||
end
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "#{repository.owner}/#{repository.name} security")
|
||||
|> assign(
|
||||
:meta_description,
|
||||
repository_meta_description(repository)
|
||||
)
|
||||
|> assign(
|
||||
:canonical_path,
|
||||
TarakanWeb.RepositoryPaths.repository_security_path(repository)
|
||||
)
|
||||
|> assign(:repository, repository)
|
||||
|> assign(
|
||||
:open_bounties,
|
||||
Tarakan.Market.open_bounties_for_target(:repository, repository.id)
|
||||
)
|
||||
|> assign(:task_form, empty_task_form(repository))
|
||||
|> assign(:show_task_form, false)
|
||||
|> assign(:task_kind_options, task_kind_options())
|
||||
|> assign(:capability_options, capability_options())
|
||||
|> assign(:branch_options, [])
|
||||
|> assign(:selected_branch, repository.default_branch)
|
||||
|> assign(:can_auto_open_job, can_auto_open_job?(socket, repository))
|
||||
|> assign(:moderation_form, moderation_form())
|
||||
|> assign(:can_vote, can_vote?(socket))
|
||||
|> assign(:can_manage_disclosure, can_manage_disclosure?(socket, repository))
|
||||
|> assign(:canonical_findings, [])
|
||||
|> assign(:checkable_findings_count, 0)
|
||||
|> assign(:posture, SecurityPosture.cached_repository_posture!(repository))
|
||||
|> assign(:regressions, FindingMemory.list_repository_regressions(repository, limit: 10))
|
||||
|> stream(:tasks, Work.list_tasks(repository, scope: socket.assigns.current_scope))
|
||||
|> load_scans()}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:scan_submitted, scan}, socket) do
|
||||
{:noreply, sync_visible_scan(socket, scan, at: 0)}
|
||||
end
|
||||
|
||||
def handle_info({:scan_updated, scan}, socket) do
|
||||
{:noreply, sync_visible_scan(socket, scan)}
|
||||
end
|
||||
|
||||
def handle_info(
|
||||
{:repository_record_updated, %Repository{id: repository_id}},
|
||||
%{assigns: %{repository: %Repository{id: repository_id}}} = socket
|
||||
) do
|
||||
{:noreply, refresh_visible_repository(socket)}
|
||||
end
|
||||
|
||||
def handle_info({event, %Repository{}}, socket)
|
||||
when event in [:repository_registered, :repository_record_updated] do
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_info({event, _task_id}, socket)
|
||||
when event in [
|
||||
:review_task_created,
|
||||
:review_task_updated,
|
||||
:review_task_published,
|
||||
:review_task_submitted,
|
||||
:review_task_accepted,
|
||||
:review_task_disclosed,
|
||||
:review_task_changes_requested,
|
||||
:review_task_rejected,
|
||||
:review_task_cancelled,
|
||||
:review_task_quarantined
|
||||
] do
|
||||
{:noreply, reload_tasks(socket)}
|
||||
end
|
||||
|
||||
def handle_info({:vote_changed, "canonical_finding", _id}, socket) do
|
||||
{:noreply, load_scans(socket)}
|
||||
end
|
||||
|
||||
def handle_info({:vote_changed, _type, _id}, socket), do: {:noreply, socket}
|
||||
|
||||
@impl true
|
||||
def handle_event("toggle_task_form", _params, socket) do
|
||||
show = !socket.assigns.show_task_form
|
||||
|
||||
socket =
|
||||
if show do
|
||||
socket
|
||||
|> assign(:show_task_form, true)
|
||||
|> load_branch_options()
|
||||
|> assign(:task_form, draft_task_form(socket.assigns.repository, %{}))
|
||||
else
|
||||
assign(socket, :show_task_form, false)
|
||||
end
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("select_branch", %{"branch" => branch}, socket) do
|
||||
repository = socket.assigns.repository
|
||||
branch = String.trim(to_string(branch || ""))
|
||||
|
||||
case RepositoryCode.resolve_branch_commit(repository, branch) do
|
||||
{:ok, commit_sha} ->
|
||||
params =
|
||||
(socket.assigns.task_form.params || %{})
|
||||
|> Map.put("commit_sha", commit_sha)
|
||||
|
||||
# Clear title so it re-fills with the new short SHA.
|
||||
params = Map.put(params, "title", "")
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:selected_branch, branch)
|
||||
|> assign(:task_form, draft_task_form(repository, params))}
|
||||
|
||||
{:error, _reason} ->
|
||||
{:noreply,
|
||||
put_flash(socket, :error, "Could not resolve branch #{inspect(branch)} to a commit.")}
|
||||
end
|
||||
end
|
||||
|
||||
# One-click mass path: default-branch HEAD, code_review, agent, auto title/description.
|
||||
# Stewards/moderators get an immediately open job; others get a proposal.
|
||||
def handle_event(
|
||||
"quick_open_job",
|
||||
_params,
|
||||
%{assigns: %{current_scope: %{account: account}}} = socket
|
||||
)
|
||||
when not is_nil(account) do
|
||||
repository = socket.assigns.repository
|
||||
|
||||
params =
|
||||
case RepositoryCode.resolve_default_commit(repository) do
|
||||
{:ok, commit_sha} -> %{"commit_sha" => commit_sha}
|
||||
{:error, _} -> %{}
|
||||
end
|
||||
|
||||
create_task_from_params(socket, params)
|
||||
end
|
||||
|
||||
def handle_event("quick_open_job", _params, socket) do
|
||||
{:noreply, redirect(socket, to: ~p"/accounts/log-in")}
|
||||
end
|
||||
|
||||
def handle_event(
|
||||
"vote",
|
||||
%{"type" => subject_type, "id" => subject_id, "vote" => value},
|
||||
%{assigns: %{current_scope: %{account: account}}} = socket
|
||||
)
|
||||
when not is_nil(account) do
|
||||
with {:ok, subject_id} <- normalize_id(subject_id),
|
||||
{:ok, value} <- normalize_vote(value) do
|
||||
case Reputation.cast_vote(
|
||||
socket.assigns.current_scope,
|
||||
subject_type,
|
||||
subject_id,
|
||||
value
|
||||
) do
|
||||
{:ok, _summary} ->
|
||||
{:noreply, load_scans(socket)}
|
||||
|
||||
{:error, :own_content} ->
|
||||
{:noreply, put_flash(socket, :error, "You cannot vote on your own contribution.")}
|
||||
|
||||
{:error, _reason} ->
|
||||
{:noreply, put_flash(socket, :error, "Your vote could not be recorded.")}
|
||||
end
|
||||
else
|
||||
_invalid -> {:noreply, put_flash(socket, :error, "Your vote could not be recorded.")}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("vote", _params, socket) do
|
||||
{:noreply, redirect(socket, to: ~p"/accounts/log-in")}
|
||||
end
|
||||
|
||||
def handle_event(
|
||||
"toggle_managed_disclosure",
|
||||
_params,
|
||||
%{assigns: %{current_scope: %{account: account}}} = socket
|
||||
)
|
||||
when not is_nil(account) do
|
||||
repository = socket.assigns.repository
|
||||
|
||||
case Repositories.set_managed_disclosure(
|
||||
socket.assigns.current_scope,
|
||||
repository,
|
||||
not repository.managed_disclosure
|
||||
) do
|
||||
{:ok, _updated} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> reload_repository()
|
||||
|> put_flash(:info, "Managed disclosure updated.")}
|
||||
|
||||
{:error, reason} when reason in [:unauthorized, :not_found] ->
|
||||
{:noreply, put_flash(socket, :error, "You cannot change managed disclosure.")}
|
||||
|
||||
{:error, _reason} ->
|
||||
{:noreply, put_flash(socket, :error, "Managed disclosure could not be updated.")}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("toggle_managed_disclosure", _params, socket) do
|
||||
{:noreply, redirect(socket, to: ~p"/accounts/log-in")}
|
||||
end
|
||||
|
||||
# Fills the proposal form with the selected (or default) branch tip.
|
||||
def handle_event("use_default_commit", _params, socket) do
|
||||
repository = socket.assigns.repository
|
||||
branch = socket.assigns.selected_branch || repository.default_branch
|
||||
|
||||
result =
|
||||
if is_binary(branch) and branch != "" do
|
||||
RepositoryCode.resolve_branch_commit(repository, branch)
|
||||
else
|
||||
RepositoryCode.resolve_default_commit(repository)
|
||||
end
|
||||
|
||||
case result do
|
||||
{:ok, commit_sha} ->
|
||||
params =
|
||||
(socket.assigns.task_form.params || %{})
|
||||
|> Map.put("commit_sha", commit_sha)
|
||||
|> Map.put("title", "")
|
||||
|
||||
{:noreply, assign(socket, :task_form, draft_task_form(repository, params))}
|
||||
|
||||
{:error, _reason} ->
|
||||
{:noreply, put_flash(socket, :error, "The branch tip could not be resolved to a commit.")}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("validate_task", %{"review_task" => params}, socket) do
|
||||
params = normalize_task_params(params)
|
||||
form = draft_task_form(socket.assigns.repository, params, action: :validate)
|
||||
{:noreply, assign(socket, :task_form, form)}
|
||||
end
|
||||
|
||||
def handle_event(
|
||||
"create_task",
|
||||
%{"review_task" => params},
|
||||
%{assigns: %{current_scope: %{account: account}}} = socket
|
||||
)
|
||||
when not is_nil(account) do
|
||||
create_task_from_params(socket, normalize_task_params(params))
|
||||
end
|
||||
|
||||
def handle_event("create_task", _params, socket) do
|
||||
{:noreply, redirect(socket, to: ~p"/accounts/log-in")}
|
||||
end
|
||||
|
||||
def handle_event(
|
||||
"cancel_task",
|
||||
%{"id" => id},
|
||||
%{assigns: %{current_scope: %{account: account}}} = socket
|
||||
)
|
||||
when not is_nil(account) do
|
||||
task =
|
||||
Work.list_tasks(socket.assigns.repository, scope: socket.assigns.current_scope)
|
||||
|> Enum.find(&(to_string(&1.id) == to_string(id)))
|
||||
|
||||
case task do
|
||||
nil ->
|
||||
{:noreply, put_flash(socket, :error, "Job not found.")}
|
||||
|
||||
task ->
|
||||
case Work.cancel_task(task, socket.assigns.current_scope, %{
|
||||
"reason" => "Cancelled from the repository security page by a steward or creator."
|
||||
}) do
|
||||
{:ok, _cancelled} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> reload_tasks()
|
||||
|> put_flash(:info, "Job cancelled.")}
|
||||
|
||||
{:error, :unauthorized} ->
|
||||
{:noreply, put_flash(socket, :error, "You cannot cancel this job.")}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, put_flash(socket, :error, "Could not cancel this job.")}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("cancel_task", _params, socket) do
|
||||
{:noreply, redirect(socket, to: ~p"/accounts/log-in")}
|
||||
end
|
||||
|
||||
def handle_event(
|
||||
"record_finding_verdict",
|
||||
%{
|
||||
"finding_id" => public_id,
|
||||
"commit_sha" => commit_sha,
|
||||
"verdict" => verdict,
|
||||
"notes" => notes
|
||||
},
|
||||
%{assigns: %{current_scope: %{account: account}}} = socket
|
||||
)
|
||||
when not is_nil(account) do
|
||||
case apply_finding_check(socket, public_id, commit_sha, verdict, notes) do
|
||||
:ok ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> reload_repository()
|
||||
|> load_scans()
|
||||
|> put_flash(:info, "Finding check recorded.")}
|
||||
|
||||
{:error, message} ->
|
||||
{:noreply, put_flash(socket, :error, message)}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("record_finding_verdict", _params, socket) do
|
||||
{:noreply, redirect(socket, to: ~p"/accounts/log-in")}
|
||||
end
|
||||
|
||||
def handle_event(
|
||||
"bulk_check_findings",
|
||||
%{"verdict" => verdict, "notes" => notes},
|
||||
%{assigns: %{current_scope: %{account: account}}} = socket
|
||||
)
|
||||
when not is_nil(account) and verdict in ["confirmed", "disputed", "fixed"] do
|
||||
notes = notes |> to_string() |> String.trim()
|
||||
|
||||
if String.length(notes) < 20 do
|
||||
{:noreply, put_flash(socket, :error, "Notes need at least 20 characters.")}
|
||||
else
|
||||
targets =
|
||||
socket.assigns.canonical_findings
|
||||
|> Enum.filter(& &1.can_check)
|
||||
|> Enum.map(fn finding ->
|
||||
{finding.public_id, finding.last_seen_commit_sha}
|
||||
end)
|
||||
|
||||
{ok, fail} =
|
||||
Enum.reduce(targets, {0, 0}, fn {public_id, commit_sha}, {ok, fail} ->
|
||||
case apply_finding_check(socket, public_id, commit_sha, verdict, notes) do
|
||||
:ok -> {ok + 1, fail}
|
||||
{:error, _} -> {ok, fail + 1}
|
||||
end
|
||||
end)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> reload_repository()
|
||||
|> load_scans()
|
||||
|
||||
msg =
|
||||
cond do
|
||||
ok == 0 and fail == 0 ->
|
||||
"No findings available to check."
|
||||
|
||||
fail == 0 ->
|
||||
"Recorded #{ok} finding check#{if ok == 1, do: "", else: "s"}."
|
||||
|
||||
ok == 0 ->
|
||||
"Could not record any finding checks."
|
||||
|
||||
true ->
|
||||
"Recorded #{ok} check#{if ok == 1, do: "", else: "s"}; #{fail} failed."
|
||||
end
|
||||
|
||||
flash = if ok > 0, do: :info, else: :error
|
||||
{:noreply, put_flash(socket, flash, msg)}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("bulk_check_findings", _params, socket) do
|
||||
{:noreply, redirect(socket, to: ~p"/accounts/log-in")}
|
||||
end
|
||||
|
||||
def handle_event(
|
||||
"moderate_scan",
|
||||
%{"scan_id" => scan_id, "decision" => decision, "moderation" => attrs},
|
||||
%{assigns: %{current_scope: %{account: account}}} = socket
|
||||
)
|
||||
when not is_nil(account) do
|
||||
with_recent_auth(socket, fn ->
|
||||
with {:ok, scan} <- Scans.get_scan(socket.assigns.current_scope, scan_id) do
|
||||
# Reason codes are machine labels for the audit log. The decision button
|
||||
# picks them - moderators only write evidence notes in the UI.
|
||||
attrs =
|
||||
attrs
|
||||
|> stringify_moderation_attrs()
|
||||
|> Map.put("moderation_reason", default_moderation_reason(decision))
|
||||
|
||||
result =
|
||||
case decision do
|
||||
"accept" ->
|
||||
Scans.accept_scan(socket.assigns.current_scope, scan, attrs)
|
||||
|
||||
"reject" ->
|
||||
Scans.reject_scan(socket.assigns.current_scope, scan, attrs)
|
||||
|
||||
"contest" ->
|
||||
Scans.contest_scan(socket.assigns.current_scope, scan, attrs)
|
||||
|
||||
"publish_summary" ->
|
||||
Scans.update_visibility(socket.assigns.current_scope, scan, "public_summary", attrs)
|
||||
|
||||
"publish_full" ->
|
||||
Scans.update_visibility(socket.assigns.current_scope, scan, "public", attrs)
|
||||
|
||||
"restrict" ->
|
||||
Scans.update_visibility(socket.assigns.current_scope, scan, "restricted", attrs)
|
||||
|
||||
_other ->
|
||||
{:error, :invalid_transition}
|
||||
end
|
||||
|
||||
case result do
|
||||
{:ok, updated_scan} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:moderation_form, moderation_form())
|
||||
|> sync_visible_scan(updated_scan)
|
||||
|> put_flash(:info, "Review decision recorded.")}
|
||||
|
||||
{:error, :verification_required} ->
|
||||
{:noreply, put_flash(socket, :error, "Acceptance requires verification quorum.")}
|
||||
|
||||
{:error, reason} when reason in [:unauthorized, :conflict_of_interest] ->
|
||||
{:noreply, put_flash(socket, :error, "You cannot moderate this review.")}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:moderation_form, to_form(changeset, as: :moderation))
|
||||
|> put_flash(:error, "Add evidence notes (20+ characters).")}
|
||||
|
||||
{:error, _reason} ->
|
||||
{:noreply, put_flash(socket, :error, "Review decision could not be recorded.")}
|
||||
end
|
||||
else
|
||||
{:error, :not_found} ->
|
||||
{:noreply, put_flash(socket, :error, "Review is unavailable.")}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
def handle_event("moderate_scan", _params, socket) do
|
||||
{:noreply, redirect(socket, to: ~p"/accounts/log-in")}
|
||||
end
|
||||
|
||||
defp apply_finding_check(socket, public_id, commit_sha, verdict, notes) do
|
||||
attrs = %{
|
||||
"commit_sha" => commit_sha,
|
||||
"verdict" => verdict,
|
||||
"provenance" => "human",
|
||||
"notes" => notes
|
||||
}
|
||||
|
||||
case FindingMemory.record_check(
|
||||
socket.assigns.current_scope,
|
||||
socket.assigns.repository,
|
||||
public_id,
|
||||
attrs
|
||||
) do
|
||||
{:ok, _check, _canonical} ->
|
||||
:ok
|
||||
|
||||
{:error, :conflict_of_interest} ->
|
||||
{:error, "You cannot verify a finding you submitted."}
|
||||
|
||||
{:error, :unauthorized} ->
|
||||
{:error, "You are not authorized to verify this finding."}
|
||||
|
||||
{:error, %Ecto.Changeset{errors: [{_field, {message, _meta}} | _]}} ->
|
||||
{:error, "Finding check not recorded: #{message}."}
|
||||
|
||||
{:error, _reason} ->
|
||||
{:error, "Finding check could not be recorded."}
|
||||
end
|
||||
end
|
||||
|
||||
defp reload_repository(socket) do
|
||||
%{host: host, owner: owner, name: name} = socket.assigns.repository
|
||||
assign(socket, :repository, Repositories.get_repository(host, owner, name))
|
||||
end
|
||||
|
||||
defp refresh_visible_repository(socket) do
|
||||
%{host: host, owner: owner, name: name} = socket.assigns.repository
|
||||
|
||||
case Repositories.get_visible_repository(host, owner, name, socket.assigns.current_scope) do
|
||||
nil ->
|
||||
push_navigate(socket, to: ~p"/")
|
||||
|
||||
repository ->
|
||||
findings =
|
||||
canonical_findings(
|
||||
repository,
|
||||
socket.assigns.current_scope,
|
||||
current_account_id(socket)
|
||||
)
|
||||
|
||||
scans = Scans.list_scans(socket.assigns.current_scope, repository)
|
||||
|
||||
socket
|
||||
|> assign(:repository, repository)
|
||||
|> assign(:canonical_findings, findings)
|
||||
|> assign(:checkable_findings_count, Enum.count(findings, & &1.can_check))
|
||||
|> stream(:tasks, Work.list_tasks(repository, scope: socket.assigns.current_scope),
|
||||
reset: true
|
||||
)
|
||||
|> assign(:task_target_options, task_target_options(scans))
|
||||
|> stream(:scans, scans, reset: true)
|
||||
end
|
||||
end
|
||||
|
||||
defp reload_tasks(socket) do
|
||||
tasks = Work.list_tasks(socket.assigns.repository, scope: socket.assigns.current_scope)
|
||||
stream(socket, :tasks, tasks, reset: true)
|
||||
end
|
||||
|
||||
# Raw reports are immutable provenance. Shared votes and checks are loaded
|
||||
# once for each canonical issue instead of being repeated on occurrences.
|
||||
defp load_scans(socket) do
|
||||
scans = Scans.list_scans(socket.assigns.current_scope, socket.assigns.repository)
|
||||
|
||||
findings =
|
||||
canonical_findings(
|
||||
socket.assigns.repository,
|
||||
socket.assigns.current_scope,
|
||||
current_account_id(socket)
|
||||
)
|
||||
|
||||
socket
|
||||
|> assign(:canonical_findings, findings)
|
||||
|> assign(:checkable_findings_count, Enum.count(findings, & &1.can_check))
|
||||
|> assign(:task_target_options, task_target_options(scans))
|
||||
|> stream(:scans, scans, reset: true)
|
||||
end
|
||||
|
||||
defp canonical_findings(repository, scope, account_id) do
|
||||
findings = FindingMemory.list_repository_memory(repository, limit: 200)
|
||||
|
||||
votes =
|
||||
Reputation.vote_summaries("canonical_finding", Enum.map(findings, & &1.id), account_id)
|
||||
|
||||
checkable_ids = FindingMemory.checkable_public_ids(scope, repository, findings)
|
||||
|
||||
findings
|
||||
|> Enum.map(fn finding ->
|
||||
Map.merge(finding, %{
|
||||
can_check: MapSet.member?(checkable_ids, finding.public_id),
|
||||
vote_summary: Map.get(votes, finding.id, %{score: 0, my_vote: 0})
|
||||
})
|
||||
end)
|
||||
end
|
||||
|
||||
defp current_account_id(%{assigns: %{current_scope: %{account: %{id: id}}}}), do: id
|
||||
defp current_account_id(_socket), do: nil
|
||||
|
||||
# phx-value params are client-controlled; never feed them to String.to_integer/1.
|
||||
defp normalize_id(id) when is_integer(id) and id > 0, do: {:ok, id}
|
||||
|
||||
defp normalize_id(id) when is_binary(id) do
|
||||
case Integer.parse(id) do
|
||||
{parsed, ""} when parsed > 0 -> {:ok, parsed}
|
||||
_other -> {:error, :invalid_id}
|
||||
end
|
||||
end
|
||||
|
||||
defp normalize_id(_id), do: {:error, :invalid_id}
|
||||
|
||||
defp normalize_vote(value) when is_integer(value), do: {:ok, value}
|
||||
|
||||
defp normalize_vote(value) when is_binary(value) do
|
||||
case Integer.parse(value) do
|
||||
{parsed, ""} -> {:ok, parsed}
|
||||
_other -> {:error, :invalid_vote}
|
||||
end
|
||||
end
|
||||
|
||||
defp normalize_vote(_value), do: {:error, :invalid_vote}
|
||||
|
||||
defp can_vote?(socket), do: not is_nil(current_account_id(socket))
|
||||
|
||||
defp can_manage_disclosure?(socket, repository) do
|
||||
Policy.allowed?(socket.assigns.current_scope, :moderate, repository)
|
||||
end
|
||||
|
||||
defp create_task_from_params(socket, params) do
|
||||
params = normalize_task_params(params)
|
||||
|
||||
case Work.create_task(socket.assigns.repository, socket.assigns.current_scope, params) do
|
||||
{:ok, task} ->
|
||||
message =
|
||||
if task.status == "open" do
|
||||
"Job opened on the public queue."
|
||||
else
|
||||
"Job proposed for independent approval."
|
||||
end
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:task_form, empty_task_form(socket.assigns.repository))
|
||||
|> assign(:show_task_form, false)
|
||||
|> stream_insert(:tasks, task, at: 0)
|
||||
|> put_flash(:info, message)}
|
||||
|
||||
{:error, :commit_not_found} ->
|
||||
{:noreply, assign_task_error(socket, params, :commit_sha, "commit was not found")}
|
||||
|
||||
{:error, :commit_mismatch} ->
|
||||
{:noreply,
|
||||
assign_task_error(socket, params, :commit_sha, "commit verification returned a mismatch")}
|
||||
|
||||
{:error, reason} when reason in [:rate_limited, :unavailable] ->
|
||||
{:noreply,
|
||||
assign_task_error(socket, params, :commit_sha, "commit could not be verified right now")}
|
||||
|
||||
{:error, reason} when reason in [:identity_changed, :not_public] ->
|
||||
{:noreply,
|
||||
assign_task_error(
|
||||
socket,
|
||||
params,
|
||||
:commit_sha,
|
||||
"repository identity is no longer confirmed public (GitHub-backed repos only - Tarakan-hosted repos use the local git object database)"
|
||||
)}
|
||||
|
||||
{:error, :proposal_limit} ->
|
||||
{:noreply, put_flash(socket, :error, "Daily review-task proposal limit reached.")}
|
||||
|
||||
{:error, :proposal_rate_limited} ->
|
||||
{:noreply, put_flash(socket, :error, "Too many task proposals. Try again shortly.")}
|
||||
|
||||
{:error, :duplicate_job} ->
|
||||
{:noreply,
|
||||
put_flash(
|
||||
socket,
|
||||
:error,
|
||||
"An open job already covers this commit and job type. Claim or complete that one first."
|
||||
)}
|
||||
|
||||
{:error, :unauthorized} ->
|
||||
{:noreply, put_flash(socket, :error, "This account cannot propose jobs.")}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:show_task_form, true)
|
||||
|> assign(:task_form, to_form(changeset, as: :review_task))}
|
||||
end
|
||||
end
|
||||
|
||||
defp assign_task_error(socket, params, field, message) do
|
||||
filled = Work.fill_task_defaults(socket.assigns.repository, params)
|
||||
|
||||
form =
|
||||
%ReviewTask{}
|
||||
|> Work.change_task(filled)
|
||||
|> Ecto.Changeset.add_error(field, message)
|
||||
|> Map.put(:action, :validate)
|
||||
|> to_form(as: :review_task)
|
||||
|
||||
socket
|
||||
|> assign(:show_task_form, true)
|
||||
|> assign(:task_form, form)
|
||||
end
|
||||
|
||||
defp empty_task_form(repository) do
|
||||
draft_task_form(repository, %{})
|
||||
end
|
||||
|
||||
defp draft_task_form(repository, params, opts \\ []) do
|
||||
params =
|
||||
params
|
||||
|> maybe_default_commit(repository)
|
||||
|> then(&Work.fill_task_defaults(repository, &1))
|
||||
|
||||
changeset =
|
||||
%ReviewTask{}
|
||||
|> Work.change_task(params)
|
||||
|
||||
changeset =
|
||||
case Keyword.get(opts, :action) do
|
||||
nil -> changeset
|
||||
action -> Map.put(changeset, :action, action)
|
||||
end
|
||||
|
||||
to_form(changeset, as: :review_task)
|
||||
end
|
||||
|
||||
defp load_branch_options(socket) do
|
||||
repository = socket.assigns.repository
|
||||
|
||||
case RepositoryCode.list_branches(repository) do
|
||||
{:ok, branches} ->
|
||||
default = repository.default_branch
|
||||
selected = socket.assigns[:selected_branch] || default || List.first(branches)
|
||||
|
||||
socket
|
||||
|> assign(:branch_options, branches)
|
||||
|> assign(:selected_branch, selected)
|
||||
|
||||
{:error, _} ->
|
||||
default = repository.default_branch
|
||||
|
||||
branches =
|
||||
if is_binary(default) and default != "", do: [default], else: []
|
||||
|
||||
socket
|
||||
|> assign(:branch_options, branches)
|
||||
|> assign(:selected_branch, default)
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_default_commit(params, repository) do
|
||||
params = for {k, v} <- params, into: %{}, do: {to_string(k), v}
|
||||
|
||||
if present_param?(params["commit_sha"]) do
|
||||
params
|
||||
else
|
||||
case RepositoryCode.resolve_default_commit(repository) do
|
||||
{:ok, commit_sha} -> Map.put(params, "commit_sha", commit_sha)
|
||||
{:error, _} -> params
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp present_param?(value) when value in [nil, ""], do: false
|
||||
defp present_param?(value) when is_binary(value), do: String.trim(value) != ""
|
||||
defp present_param?(_), do: true
|
||||
|
||||
defp can_auto_open_job?(%{assigns: %{current_scope: scope}}, repository)
|
||||
when not is_nil(scope) do
|
||||
# publish_task is checked against the repository for stewards; moderators always can.
|
||||
Policy.allowed?(scope, :publish_task, repository) or
|
||||
Policy.allowed?(scope, :manage_repository, repository)
|
||||
end
|
||||
|
||||
defp can_auto_open_job?(_socket, _repository), do: false
|
||||
|
||||
defp can_cancel_job?(task, %{account: %{id: account_id}} = scope) when not is_nil(account_id) do
|
||||
task.status in ["proposed", "open", "claimed", "changes_requested"] and
|
||||
Policy.allowed?(scope, :cancel_task, task)
|
||||
end
|
||||
|
||||
defp can_cancel_job?(_task, _scope), do: false
|
||||
|
||||
defp can_moderate?(scan, %{account: account} = scope) when not is_nil(account) do
|
||||
Policy.allowed?(scope, :moderate_review, scan) and scan.submitted_by_id != account.id
|
||||
end
|
||||
|
||||
defp can_moderate?(_scan, _scope), do: false
|
||||
|
||||
defp moderation_form do
|
||||
to_form(
|
||||
%{
|
||||
"visibility" => "restricted",
|
||||
"moderation_reason" => "",
|
||||
"moderation_notes" => ""
|
||||
},
|
||||
as: :moderation
|
||||
)
|
||||
end
|
||||
|
||||
defp stringify_moderation_attrs(attrs) when is_map(attrs) do
|
||||
Map.new(attrs, fn {k, v} -> {to_string(k), v} end)
|
||||
end
|
||||
|
||||
defp stringify_moderation_attrs(_), do: %{}
|
||||
|
||||
# Stored on the scan for audit/activity feeds. Not a user-facing concept.
|
||||
defp default_moderation_reason("accept"), do: "evidence_reviewed"
|
||||
defp default_moderation_reason("reject"), do: "evidence_insufficient"
|
||||
defp default_moderation_reason("contest"), do: "independently_contested"
|
||||
defp default_moderation_reason("publish_full"), do: "disclosure_reviewed"
|
||||
defp default_moderation_reason("publish_summary"), do: "safe_summary"
|
||||
defp default_moderation_reason("restrict"), do: "takedown_review"
|
||||
defp default_moderation_reason(_), do: "moderator_decision"
|
||||
|
||||
defp with_recent_auth(socket, fun) do
|
||||
if Accounts.sudo_mode?(socket.assigns.current_scope.account) do
|
||||
fun.()
|
||||
else
|
||||
repo = socket.assigns.repository
|
||||
return_to = TarakanWeb.RepositoryPaths.repository_path(repo)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(
|
||||
:error,
|
||||
"Confirm it's you with a magic link before changing review disclosure (sign-in older than 8 hours)."
|
||||
)
|
||||
|> push_navigate(to: TarakanWeb.AccountAuth.reauth_path(return_to))}
|
||||
end
|
||||
end
|
||||
|
||||
defp sync_visible_scan(socket, scan, opts \\ []) do
|
||||
socket = reload_repository(socket)
|
||||
|
||||
socket =
|
||||
case Scans.get_scan(socket.assigns.current_scope, scan.id) do
|
||||
{:ok, visible_scan} -> stream_insert(socket, :scans, visible_scan, opts)
|
||||
{:error, :not_found} -> stream_delete(socket, :scans, scan)
|
||||
end
|
||||
|
||||
scans = Scans.list_scans(socket.assigns.current_scope, socket.assigns.repository)
|
||||
assign(socket, :task_target_options, task_target_options(scans))
|
||||
end
|
||||
|
||||
defp short_sha(sha), do: String.slice(sha, 0, 7)
|
||||
|
||||
defp scan_time(%DateTime{} = datetime) do
|
||||
Calendar.strftime(datetime, "%Y-%m-%d %H:%M")
|
||||
end
|
||||
|
||||
# Signal is reserved for findings; quiet states stay quiet.
|
||||
|
||||
defp finding_lines(%{line_start: nil}), do: ""
|
||||
defp finding_lines(%{line_start: line, line_end: line}), do: ":#{line}"
|
||||
|
||||
defp finding_lines(%{line_start: line_start, line_end: line_end}),
|
||||
do: ":#{line_start}-#{line_end}"
|
||||
|
||||
defp repository_meta_description(repository) do
|
||||
label = TarakanWeb.RepositoryComponents.repository_status_label(repository)
|
||||
host = repository.host || "github.com"
|
||||
|
||||
base =
|
||||
"Public security record for #{repository.owner}/#{repository.name} on #{host}. " <>
|
||||
"#{label}."
|
||||
|
||||
detail =
|
||||
cond do
|
||||
(repository.open_findings_count || 0) > 0 ->
|
||||
" See open findings and open jobs."
|
||||
|
||||
true ->
|
||||
" Contribute a review or claim an open job."
|
||||
end
|
||||
|
||||
String.slice(base <> detail, 0, 160)
|
||||
end
|
||||
|
||||
# Mass UI: Report job + Check job first; advanced kinds still available.
|
||||
defp task_kind_options do
|
||||
primary = [
|
||||
{"Security report (findings)", "code_review"},
|
||||
{"Check an existing report", "verify_findings"}
|
||||
]
|
||||
|
||||
advanced =
|
||||
~w(diff_review threat_model privacy_review business_logic write_fix)
|
||||
|> Enum.map(&{review_kind_label(&1) <> " (advanced)", &1})
|
||||
|
||||
primary ++ advanced
|
||||
end
|
||||
|
||||
defp task_target_options(scans) do
|
||||
scans
|
||||
|> Enum.filter(&(&1.findings_count > 0))
|
||||
|> Enum.map(fn scan ->
|
||||
count_label =
|
||||
if scan.findings_count == 1, do: "1 finding", else: "#{scan.findings_count} findings"
|
||||
|
||||
{
|
||||
"Report ##{scan.id} · #{review_kind_label(scan.review_kind)} · #{short_sha(scan.commit_sha)} · #{count_label}",
|
||||
scan.id
|
||||
}
|
||||
end)
|
||||
end
|
||||
|
||||
# Each kind carries exactly one extra field. Leaving a stale value from a
|
||||
# different kind in the params makes the changeset reject it, which reads to
|
||||
# the user as an unrelated validation error.
|
||||
defp normalize_task_params(%{"kind" => "verify_findings"} = params) do
|
||||
Map.delete(params, "base_commit_sha")
|
||||
end
|
||||
|
||||
defp normalize_task_params(%{"kind" => "diff_review"} = params) do
|
||||
Map.delete(params, "target_review_id")
|
||||
end
|
||||
|
||||
defp normalize_task_params(params) do
|
||||
params |> Map.delete("target_review_id") |> Map.delete("base_commit_sha")
|
||||
end
|
||||
|
||||
# Job form: only who should pick it up. Hybrid stays a report provenance
|
||||
# (agent draft a person edited), not a separate "human-guided" work mode.
|
||||
defp capability_options do
|
||||
[
|
||||
{"AI helper", "agent"},
|
||||
{"Human only", "human"}
|
||||
]
|
||||
end
|
||||
|
||||
defp short_task_status(%{status: "claimed"} = task) do
|
||||
if Tarakan.Work.ReviewTask.claim_active?(task), do: "Claimed", else: "Open"
|
||||
end
|
||||
|
||||
defp short_task_status(%{status: "changes_requested"}), do: "Changes requested"
|
||||
|
||||
defp short_task_status(%{status: status}), do: String.capitalize(status)
|
||||
|
||||
defp empty_review_label(%{review_kind: "code_review"}), do: "No findings reported"
|
||||
defp empty_review_label(_scan), do: "Reviewed"
|
||||
end
|
||||
687
lib/tarakan_web/live/repository_live/show.html.heex
Normal file
687
lib/tarakan_web/live/repository_live/show.html.heex
Normal file
|
|
@ -0,0 +1,687 @@
|
|||
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
|
||||
<Layouts.page>
|
||||
<.repository_header repository={@repository} active_tab={:security} />
|
||||
|
||||
<TarakanWeb.BountyComponents.wanted_banner bounties={@open_bounties} />
|
||||
|
||||
<div
|
||||
:if={@can_manage_disclosure}
|
||||
id="managed-disclosure-controls"
|
||||
class="mt-4 flex flex-wrap items-center gap-3 border border-rule bg-panel px-4 py-3"
|
||||
>
|
||||
<p class="text-xs text-ink-muted">
|
||||
Managed disclosure is
|
||||
<span class="font-semibold text-ink">
|
||||
{if @repository.managed_disclosure, do: "on", else: "off"}
|
||||
</span>
|
||||
for this repository. Staff triage findings with the vendor; the public record is unchanged.
|
||||
</p>
|
||||
<button
|
||||
id="toggle-managed-disclosure"
|
||||
type="button"
|
||||
phx-click="toggle_managed_disclosure"
|
||||
class="ml-auto border border-strong px-3 py-1.5 font-mono text-[11px] uppercase tracking-[0.12em] text-ink transition hover:border-quote hover:text-quote"
|
||||
>
|
||||
{if @repository.managed_disclosure, do: "Disable", else: "Enable"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<%!-- At a glance --%>
|
||||
<section
|
||||
id="security-summary"
|
||||
class="mt-6 grid border-2 border-strong sm:grid-cols-2 lg:grid-cols-4"
|
||||
>
|
||||
<div class="border-b border-rule px-4 py-4 lg:border-b-0 sm:border-r">
|
||||
<p class="text-xs font-medium text-ink-faint">
|
||||
Open findings
|
||||
</p>
|
||||
<p id="open-findings-count" class="mt-1 font-display text-3xl tabular-nums text-ink">
|
||||
{@repository.open_findings_count}
|
||||
</p>
|
||||
</div>
|
||||
<div class="border-b border-rule px-4 py-4 lg:border-b-0 lg:border-r">
|
||||
<p class="text-xs font-medium text-ink-faint">Verified</p>
|
||||
<p id="verified-findings-count" class="mt-1 font-display text-3xl tabular-nums text-ink">
|
||||
{@repository.verified_findings_count}
|
||||
</p>
|
||||
</div>
|
||||
<div class="border-b border-rule px-4 py-4 sm:border-r lg:border-b-0">
|
||||
<p class="text-xs font-medium text-ink-faint">Reports</p>
|
||||
<p id="scan-count" class="mt-1 font-display text-3xl tabular-nums text-ink">
|
||||
{@repository.scan_count}
|
||||
</p>
|
||||
</div>
|
||||
<div class="px-4 py-4">
|
||||
<p class="text-xs font-medium text-ink-faint">Median fix</p>
|
||||
<p id="median-time-to-fix" class="mt-1 font-display text-3xl tabular-nums text-ink">
|
||||
{Tarakan.SecurityPosture.format_days(@posture.median_days_to_fix)}
|
||||
</p>
|
||||
<p
|
||||
:if={@posture.oldest_open_days}
|
||||
class="mt-1 font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint"
|
||||
>
|
||||
oldest open {Tarakan.SecurityPosture.format_days(@posture.oldest_open_days)}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<%!-- Fixed, then back. Only rendered when it has actually happened. --%>
|
||||
<section
|
||||
:if={@regressions != []}
|
||||
id="finding-regressions"
|
||||
class="mt-6 border-2 border-signal"
|
||||
>
|
||||
<h2 class="border-b border-rule px-4 py-3 font-display text-sm uppercase tracking-[0.12em] text-signal">
|
||||
Regressed - {@posture.regressions_count} fixed finding(s) came back
|
||||
</h2>
|
||||
<ul class="divide-y divide-rule">
|
||||
<li
|
||||
:for={regression <- @regressions}
|
||||
id={"regression-#{regression.id}"}
|
||||
class="flex flex-wrap items-baseline gap-x-3 gap-y-1 px-4 py-3"
|
||||
>
|
||||
<.link
|
||||
navigate={~p"/findings/#{regression.canonical_finding.public_id}"}
|
||||
class="min-w-0 flex-1 truncate text-sm text-ink hover:text-signal hover:underline"
|
||||
>
|
||||
{regression.canonical_finding.title}
|
||||
</.link>
|
||||
<span class="font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint">
|
||||
fixed at {String.slice(regression.fixed_commit_sha || "", 0, 7)} · back at {String.slice(
|
||||
regression.detected_commit_sha,
|
||||
0,
|
||||
7
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<%!-- Findings first: what matters --%>
|
||||
<section id="canonical-findings" class="mt-10">
|
||||
<div class="flex flex-wrap items-end justify-between gap-3 border-b-2 border-strong pb-3">
|
||||
<h2 class="font-display text-lg uppercase tracking-[0.02em] text-ink">
|
||||
Findings
|
||||
</h2>
|
||||
<span class="font-mono text-[11px] text-ink-faint">
|
||||
{@repository.open_findings_count} unique open
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<form
|
||||
:if={@checkable_findings_count > 0}
|
||||
id="bulk-check-findings-form"
|
||||
phx-submit="bulk_check_findings"
|
||||
class="mt-4 border border-strong bg-panel px-4 py-4"
|
||||
>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-semibold text-ink">
|
||||
Check all ({@checkable_findings_count})
|
||||
</p>
|
||||
<input
|
||||
id="bulk-check-notes"
|
||||
type="text"
|
||||
name="notes"
|
||||
required
|
||||
minlength="20"
|
||||
maxlength="2000"
|
||||
placeholder="Independent evidence notes (required, 20+ chars)"
|
||||
autocomplete="off"
|
||||
class="mt-3 w-full border border-strong bg-ground px-3 py-2 font-mono text-xs text-ink placeholder:text-ink-faint focus:border-signal focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-wrap gap-2">
|
||||
<.button name="verdict" value="confirmed" size="sm">Confirm all</.button>
|
||||
<.button name="verdict" value="disputed" variant="danger" size="sm">
|
||||
Dispute all
|
||||
</.button>
|
||||
<.button name="verdict" value="fixed" size="sm">Fixed all</.button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div
|
||||
:if={@canonical_findings == []}
|
||||
id="findings-empty"
|
||||
class="border-b border-rule px-1 py-10 text-center"
|
||||
>
|
||||
<p class="text-sm font-medium text-ink">No findings yet</p>
|
||||
<p class="mt-1 text-xs leading-5 text-ink-muted">
|
||||
<code class="font-mono text-ink">tarakan worker --agent kimi</code> or open a Job below.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div :if={@canonical_findings != []} class="divide-y divide-rule border-b border-rule">
|
||||
<article
|
||||
:for={finding <- @canonical_findings}
|
||||
id={"canonical-#{finding.public_id}"}
|
||||
class="py-5"
|
||||
>
|
||||
<div class="flex flex-wrap items-start gap-3">
|
||||
<.vote_control
|
||||
subject_type="canonical_finding"
|
||||
subject_id={finding.id}
|
||||
summary={finding.vote_summary}
|
||||
can_vote={@can_vote}
|
||||
class="mt-0.5 shrink-0"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-mono text-[10px] font-semibold uppercase tracking-[0.12em] text-signal">
|
||||
{finding.severity}
|
||||
</span>
|
||||
<.notch_badge class={[
|
||||
finding.status == "verified" && "bg-ink text-ground",
|
||||
finding.status == "fixed" && "bg-quote text-ground",
|
||||
finding.status in ["open", "disputed"] && "text-ink-muted"
|
||||
]}>
|
||||
{finding.status}
|
||||
</.notch_badge>
|
||||
<.notch_badge
|
||||
:if={finding.trust && finding.trust.agent_reproduced}
|
||||
class="text-ink-muted"
|
||||
>
|
||||
Agent reproduced
|
||||
</.notch_badge>
|
||||
<.notch_badge
|
||||
:if={finding.trust && finding.trust.human_checked}
|
||||
class="text-ink-muted"
|
||||
>
|
||||
Human checked
|
||||
</.notch_badge>
|
||||
</div>
|
||||
|
||||
<h3 class="mt-1.5 text-base font-semibold leading-snug text-ink">
|
||||
<.link
|
||||
navigate={~p"/findings/#{finding.occurrence_public_id}"}
|
||||
class="transition hover:text-signal"
|
||||
>
|
||||
{finding.title}
|
||||
</.link>
|
||||
</h3>
|
||||
|
||||
<p class="mt-1 font-mono text-[11px] text-ink-faint">
|
||||
<span class="text-ink-muted">{finding.file_path}{finding_lines(finding)}</span>
|
||||
<span class="mx-1.5" aria-hidden="true">·</span>
|
||||
last at {short_sha(finding.last_seen_commit_sha)}
|
||||
</p>
|
||||
|
||||
<p class="mt-2 max-w-3xl text-sm leading-6 text-ink-muted">
|
||||
{TarakanWeb.FindingPresentation.description_excerpt(finding.description, 220)}
|
||||
</p>
|
||||
|
||||
<p class="mt-2 font-mono text-[10px] text-ink-faint">
|
||||
detected in {finding.detections_count} {if finding.detections_count == 1,
|
||||
do: "run",
|
||||
else: "runs"} · {finding.distinct_submitters_count} {if finding.distinct_submitters_count ==
|
||||
1,
|
||||
do: "submitter",
|
||||
else: "submitters"} · {finding.confirmations_count} confirmed · {finding.disputes_count} disputed
|
||||
</p>
|
||||
|
||||
<details :if={finding.can_check} class="mt-3">
|
||||
<summary class="cursor-pointer font-mono text-[11px] text-signal transition hover:underline">
|
||||
Override this finding only
|
||||
</summary>
|
||||
<form
|
||||
id={"canonical-#{finding.public_id}-check-form"}
|
||||
phx-submit="record_finding_verdict"
|
||||
class="mt-2 flex flex-wrap items-center gap-2"
|
||||
>
|
||||
<input type="hidden" name="finding_id" value={finding.public_id} />
|
||||
<input type="hidden" name="commit_sha" value={finding.last_seen_commit_sha} />
|
||||
<input
|
||||
type="text"
|
||||
name="notes"
|
||||
required
|
||||
minlength="20"
|
||||
maxlength="2000"
|
||||
placeholder="Notes for this finding only"
|
||||
autocomplete="off"
|
||||
class="min-w-0 flex-1 basis-64 border border-rule bg-transparent px-3 py-1.5 font-mono text-xs text-ink placeholder:text-ink-faint focus:outline-none focus:ring-1 focus:ring-phosphor"
|
||||
/>
|
||||
<.button name="verdict" value="confirmed" size="sm">Confirm</.button>
|
||||
<.button name="verdict" value="disputed" variant="danger" size="sm">
|
||||
Dispute
|
||||
</.button>
|
||||
<.button name="verdict" value="fixed" size="sm">Fixed</.button>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<%!-- Jobs: action --%>
|
||||
<section id="review-work" class="mt-10">
|
||||
<div class="flex flex-wrap items-end justify-between gap-3 border-b-2 border-strong pb-3">
|
||||
<h2 class="font-display text-lg uppercase tracking-[0.02em] text-ink">
|
||||
Jobs
|
||||
</h2>
|
||||
<.link
|
||||
navigate={~p"/jobs"}
|
||||
class="font-mono text-[10px] uppercase tracking-[0.12em] text-signal hover:underline"
|
||||
>
|
||||
All open jobs →
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 border border-strong bg-panel px-4 py-4">
|
||||
<%= if @current_scope && @current_scope.account do %>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
||||
<.button
|
||||
id="quick-open-job-button"
|
||||
type="button"
|
||||
variant="primary"
|
||||
phx-click="quick_open_job"
|
||||
class="w-full phx-click-loading:opacity-60 sm:w-auto"
|
||||
>
|
||||
{if @can_auto_open_job,
|
||||
do: "Open security report job",
|
||||
else: "Propose security report job"}
|
||||
</.button>
|
||||
<button
|
||||
id="propose-task-toggle"
|
||||
type="button"
|
||||
phx-click="toggle_task_form"
|
||||
class="self-start py-1 font-mono text-xs text-signal transition hover:underline sm:self-auto"
|
||||
>
|
||||
{if @show_task_form, do: "Cancel", else: "Customize…"}
|
||||
</button>
|
||||
</div>
|
||||
<p :if={!@can_auto_open_job} class="mt-2 max-w-xl text-xs leading-5 text-ink-muted">
|
||||
A steward publishes proposals before they go live.
|
||||
</p>
|
||||
|
||||
<.form
|
||||
:if={@show_task_form}
|
||||
for={@task_form}
|
||||
id="review-task-form"
|
||||
phx-change="validate_task"
|
||||
phx-submit="create_task"
|
||||
class="mt-5 border-t border-rule pt-5"
|
||||
>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label
|
||||
for="task-branch-select"
|
||||
class="mb-1.5 block text-sm font-semibold text-ink"
|
||||
>
|
||||
Branch
|
||||
</label>
|
||||
<select
|
||||
id="task-branch-select"
|
||||
name="branch"
|
||||
phx-change="select_branch"
|
||||
class="w-full border border-strong bg-ground px-3 py-2 font-mono text-sm text-ink focus:border-signal focus:outline-none"
|
||||
>
|
||||
<option
|
||||
:for={branch <- @branch_options}
|
||||
value={branch}
|
||||
selected={branch == @selected_branch}
|
||||
>
|
||||
{branch}{if branch == @repository.default_branch, do: " (default)", else: ""}
|
||||
</option>
|
||||
<option :if={@branch_options == []} value="" selected>
|
||||
{if @repository.default_branch,
|
||||
do: @repository.default_branch,
|
||||
else: "No branches listed"}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<.input
|
||||
field={@task_form[:commit_sha]}
|
||||
type="text"
|
||||
label="Commit SHA"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<button
|
||||
id="use-default-commit-button"
|
||||
type="button"
|
||||
phx-click="use_default_commit"
|
||||
class="mt-1.5 font-mono text-[11px] text-signal transition hover:underline"
|
||||
>
|
||||
Refresh tip of {@selected_branch || @repository.default_branch || "branch"}
|
||||
</button>
|
||||
</div>
|
||||
<.input
|
||||
field={@task_form[:kind]}
|
||||
type="select"
|
||||
label="Job type"
|
||||
options={@task_kind_options}
|
||||
/>
|
||||
<%!-- diff_review reviews base..head, so it needs the other end of
|
||||
the range. Everything else reviews the snapshot at commit_sha. --%>
|
||||
<div :if={@task_form[:kind].value == "diff_review"}>
|
||||
<.input
|
||||
field={@task_form[:base_commit_sha]}
|
||||
type="text"
|
||||
label="Base commit (reviews base..commit)"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div :if={@task_form[:kind].value == "verify_findings"}>
|
||||
<.input
|
||||
field={@task_form[:target_review_id]}
|
||||
type="select"
|
||||
label="Report to check"
|
||||
options={[{"Choose a report", ""} | @task_target_options]}
|
||||
/>
|
||||
<p
|
||||
:if={@task_target_options == []}
|
||||
class="mt-1.5 text-xs leading-5 text-ink-faint"
|
||||
>
|
||||
No reports with findings to check yet.
|
||||
</p>
|
||||
</div>
|
||||
<.input
|
||||
field={@task_form[:capability]}
|
||||
type="select"
|
||||
label="Who can do it"
|
||||
options={@capability_options}
|
||||
/>
|
||||
<.input
|
||||
field={@task_form[:title]}
|
||||
type="text"
|
||||
label="Title (optional)"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<.input
|
||||
field={@task_form[:description]}
|
||||
type="textarea"
|
||||
label="What to do (optional)"
|
||||
placeholder="Leave blank for an auto brief, or add focus."
|
||||
/>
|
||||
</div>
|
||||
<.button
|
||||
id="create-review-task-button"
|
||||
type="submit"
|
||||
variant="primary"
|
||||
class="mt-4 phx-submit-loading:opacity-60"
|
||||
>
|
||||
{if @can_auto_open_job, do: "Open job", else: "Propose job"}
|
||||
</.button>
|
||||
</.form>
|
||||
<% else %>
|
||||
<div id="review-task-auth-gate">
|
||||
<p class="text-sm text-ink-muted">Sign in to open or claim jobs on this repo.</p>
|
||||
<.link
|
||||
navigate={~p"/accounts/log-in"}
|
||||
class="mt-2 inline-block font-mono text-xs text-signal hover:underline"
|
||||
>
|
||||
Sign in →
|
||||
</.link>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="review-tasks"
|
||||
phx-update="stream"
|
||||
class="mt-4 divide-y divide-rule border-t border-rule"
|
||||
>
|
||||
<div
|
||||
id="review-tasks-empty"
|
||||
class="hidden py-8 text-center text-sm text-ink-faint only:block"
|
||||
>
|
||||
No jobs yet. Open one above or run <code class="font-mono text-ink">tarakan worker --agent kimi</code>.
|
||||
</div>
|
||||
<article
|
||||
:for={{id, task} <- @streams.tasks}
|
||||
id={id}
|
||||
class="grid gap-3 px-1 py-4 transition-colors hover:bg-panel sm:grid-cols-[1fr_auto] sm:items-center sm:px-2"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2 font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint">
|
||||
<span class="text-ink-muted">{review_kind_label(task.kind)}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{provenance_label(task.capability)} required</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span title={task.commit_sha}>{short_sha(task.commit_sha)}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span class="text-ink">{short_task_status(task)}</span>
|
||||
</div>
|
||||
<h3 class="mt-1.5 text-sm font-semibold text-ink">{task.title}</h3>
|
||||
<p class="mt-1 line-clamp-2 text-xs leading-5 text-ink-muted">{task.description}</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-wrap items-center gap-2">
|
||||
<.button
|
||||
id={"review-task-#{task.id}-link"}
|
||||
navigate={~p"/jobs/#{task.id}"}
|
||||
size="sm"
|
||||
>
|
||||
Open
|
||||
</.button>
|
||||
<.button
|
||||
:if={can_cancel_job?(task, @current_scope)}
|
||||
id={"review-task-#{task.id}-cancel"}
|
||||
type="button"
|
||||
variant="danger"
|
||||
size="sm"
|
||||
phx-click="cancel_task"
|
||||
phx-value-id={task.id}
|
||||
data-confirm="Cancel this job? It will leave the open queue."
|
||||
>
|
||||
Cancel
|
||||
</.button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<%!-- Raw reports last: provenance --%>
|
||||
<section id="security-record" class="mt-10">
|
||||
<div class="border-b-2 border-strong pb-3">
|
||||
<h2 class="font-display text-lg uppercase tracking-[0.02em] text-ink">
|
||||
Reports
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div id="scans" phx-update="stream" class="border-b border-rule">
|
||||
<div
|
||||
id="scans-empty"
|
||||
class="hidden px-1 py-10 text-center text-sm text-ink-faint only:block"
|
||||
>
|
||||
No reports yet.
|
||||
</div>
|
||||
|
||||
<article :for={{id, scan} <- @streams.scans} id={id} class="border-b border-rule py-5">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
||||
<.handle_link
|
||||
handle={scan.submitted_by.handle}
|
||||
class="text-sm font-semibold text-ink"
|
||||
/>
|
||||
<span
|
||||
id={"scan-#{scan.id}-provenance-attestation"}
|
||||
class="font-mono text-[11px] text-ink-muted"
|
||||
title="Submitter claim, not independently verified"
|
||||
>
|
||||
{TarakanWeb.FindingPresentation.how_made_label(scan.provenance)}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1 font-mono text-[11px] text-ink-faint">
|
||||
{review_kind_label(scan.review_kind)}
|
||||
<span class="mx-1" aria-hidden="true">·</span>
|
||||
<span title={scan.review_status}>
|
||||
{TarakanWeb.FindingPresentation.status_blurb(scan.review_status)}
|
||||
</span>
|
||||
<span class="mx-1" aria-hidden="true">·</span>
|
||||
<span title={scan.commit_sha}>{short_sha(scan.commit_sha)}</span>
|
||||
<span class="mx-1" aria-hidden="true">·</span>
|
||||
{scan_time(scan.inserted_at)}
|
||||
</p>
|
||||
</div>
|
||||
<.notch_badge :if={scan.findings_count > 0} class="shrink-0 text-signal">
|
||||
{scan.findings_count}
|
||||
{if scan.findings_count == 1, do: "finding", else: "findings"}
|
||||
</.notch_badge>
|
||||
<.notch_badge :if={scan.findings_count == 0} class="shrink-0 text-ink-muted">
|
||||
{empty_review_label(scan)}
|
||||
</.notch_badge>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:if={scan.details_visible && (scan.model || scan.prompt_version)}
|
||||
class="mt-2 font-mono text-[11px] text-ink-faint"
|
||||
>
|
||||
<span :if={scan.model}>via {scan.model}</span>
|
||||
<span :if={scan.model && scan.prompt_version}> · </span>
|
||||
<span :if={scan.prompt_version}>{scan.prompt_version}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:if={scan.details_visible && scan.notes}
|
||||
class="mt-3 max-w-3xl text-sm leading-6 text-ink-muted"
|
||||
>
|
||||
<% notes = TarakanWeb.FindingPresentation.humanize_notes(scan.notes) %>
|
||||
<p :if={notes && notes.kind == :summary} class="text-ink-muted">
|
||||
{notes.count} findings
|
||||
<span :if={notes.tops != []}>
|
||||
· top: {Enum.join(Enum.take(notes.tops, 2), "; ")}
|
||||
</span>
|
||||
</p>
|
||||
<p :if={notes && notes.kind == :plain}>{notes.text}</p>
|
||||
</div>
|
||||
|
||||
<details
|
||||
:if={scan.details_visible && scan.findings_count > 0}
|
||||
class="mt-3"
|
||||
open={scan.findings_count <= 3}
|
||||
>
|
||||
<summary class="cursor-pointer font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint transition hover:text-ink">
|
||||
Findings in this report ({scan.findings_count})
|
||||
</summary>
|
||||
<ul class="mt-3 space-y-3 border-l-2 border-rule pl-4">
|
||||
<li :for={finding <- scan.findings}>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-mono text-[10px] uppercase tracking-[0.12em] text-signal">
|
||||
{finding.severity}
|
||||
</span>
|
||||
<.notch_badge
|
||||
:if={
|
||||
finding.canonical_finding && finding.canonical_finding.detections_count > 1
|
||||
}
|
||||
class="text-ink-muted"
|
||||
>
|
||||
seen in {finding.canonical_finding.detections_count} runs
|
||||
</.notch_badge>
|
||||
<.link
|
||||
id={"finding-source-#{finding.id}"}
|
||||
navigate={~p"/findings/#{finding.public_id}/code"}
|
||||
class="font-mono text-[11px] text-ink-faint transition hover:text-ink"
|
||||
aria-label={"Open #{finding.file_path} at the finding's pinned commit"}
|
||||
>
|
||||
{finding.file_path}{finding_lines(finding)}
|
||||
</.link>
|
||||
</div>
|
||||
<h4 class="mt-0.5 text-sm font-semibold text-ink">
|
||||
<.link
|
||||
id={"finding-page-#{finding.id}"}
|
||||
navigate={~p"/findings/#{finding.public_id}"}
|
||||
class="transition hover:text-signal"
|
||||
>
|
||||
{finding.title}
|
||||
</.link>
|
||||
</h4>
|
||||
<p class="mt-1 max-w-3xl text-sm leading-6 text-ink-muted">
|
||||
{TarakanWeb.FindingPresentation.description_excerpt(finding.description, 180)}
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
|
||||
<details
|
||||
:if={scan.details_visible && scan.raw_document}
|
||||
class="mt-2"
|
||||
>
|
||||
<summary class="cursor-pointer font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint transition hover:text-ink">
|
||||
Raw JSON
|
||||
</summary>
|
||||
<pre
|
||||
id={"scan-#{scan.id}-raw-report"}
|
||||
class="mt-2 max-h-48 overflow-auto border border-rule bg-panel px-3 py-2 font-mono text-[11px] leading-5 text-ink-muted"
|
||||
>{raw_report(scan)}</pre>
|
||||
</details>
|
||||
|
||||
<p
|
||||
:if={!scan.details_visible}
|
||||
class="mt-3 text-xs leading-5 text-ink-faint"
|
||||
>
|
||||
Detailed evidence is restricted.
|
||||
</p>
|
||||
|
||||
<details
|
||||
:if={can_moderate?(scan, @current_scope)}
|
||||
class="mt-4 border-l-2 border-rule pl-4"
|
||||
>
|
||||
<summary class="cursor-pointer text-sm font-semibold text-ink transition hover:text-signal">
|
||||
Moderate this report
|
||||
</summary>
|
||||
<.form
|
||||
for={@moderation_form}
|
||||
id={"scan-#{scan.id}-moderation-form"}
|
||||
phx-submit="moderate_scan"
|
||||
class="mt-3"
|
||||
>
|
||||
<input type="hidden" name="scan_id" value={scan.id} />
|
||||
<.input
|
||||
id={"scan-#{scan.id}-moderation-notes"}
|
||||
field={@moderation_form[:moderation_notes]}
|
||||
type="textarea"
|
||||
label="What did you check?"
|
||||
placeholder="What was independently verified (20+ characters)."
|
||||
/>
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<.button
|
||||
:if={scan.review_status != "accepted"}
|
||||
name="decision"
|
||||
value="accept"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
Accept
|
||||
</.button>
|
||||
<.button name="decision" value="contest" size="sm">Contest</.button>
|
||||
<.button name="decision" value="reject" variant="danger" size="sm">
|
||||
Reject
|
||||
</.button>
|
||||
<.button
|
||||
:if={scan.visibility != "public"}
|
||||
name="decision"
|
||||
value="publish_full"
|
||||
size="sm"
|
||||
>
|
||||
Restore full evidence
|
||||
</.button>
|
||||
<.button
|
||||
:if={scan.visibility == "public"}
|
||||
name="decision"
|
||||
value="publish_summary"
|
||||
size="sm"
|
||||
>
|
||||
Redact to summary
|
||||
</.button>
|
||||
<.button
|
||||
:if={scan.visibility != "restricted"}
|
||||
name="decision"
|
||||
value="restrict"
|
||||
variant="danger"
|
||||
size="sm"
|
||||
>
|
||||
Restrict
|
||||
</.button>
|
||||
</div>
|
||||
</.form>
|
||||
</details>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</Layouts.page>
|
||||
</Layouts.app>
|
||||
450
lib/tarakan_web/live/review_task_live/show.ex
Normal file
450
lib/tarakan_web/live/review_task_live/show.ex
Normal file
|
|
@ -0,0 +1,450 @@
|
|||
defmodule TarakanWeb.ReviewTaskLive.Show do
|
||||
use TarakanWeb, :live_view
|
||||
|
||||
alias Tarakan.Accounts
|
||||
alias Tarakan.Policy
|
||||
alias Tarakan.Scans
|
||||
alias Tarakan.Work
|
||||
alias Tarakan.Work.{Contribution, ReviewTask}
|
||||
|
||||
@impl true
|
||||
def mount(%{"id" => id}, _session, socket) do
|
||||
task =
|
||||
Work.get_visible_task(id, socket.assigns.current_scope) ||
|
||||
raise Ecto.NoResultsError, queryable: ReviewTask
|
||||
|
||||
if connected?(socket), do: Work.subscribe(task.repository_id)
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, task.title)
|
||||
|> assign(:meta_description, task_meta_description(task))
|
||||
|> assign(:canonical_path, ~p"/jobs/#{task.id}")
|
||||
|> assign_task(task)
|
||||
|> assign(:decision_form, decision_form(task))
|
||||
|> assign(:disclosure_form, disclosure_form())}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({event, task_id}, %{assigns: %{task: current_task}} = socket)
|
||||
when event in [
|
||||
:review_task_updated,
|
||||
:review_task_published,
|
||||
:review_task_submitted,
|
||||
:review_task_accepted,
|
||||
:review_task_disclosed,
|
||||
:review_task_changes_requested,
|
||||
:review_task_rejected,
|
||||
:review_task_cancelled,
|
||||
:review_task_quarantined
|
||||
] do
|
||||
if task_id == current_task.id do
|
||||
case Work.get_visible_task(task_id, socket.assigns.current_scope) do
|
||||
nil -> {:noreply, push_navigate(socket, to: repository_path(current_task))}
|
||||
task -> {:noreply, assign_task(socket, task)}
|
||||
end
|
||||
else
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("claim", _params, %{assigns: %{current_scope: scope, task: task}} = socket) do
|
||||
respond(socket, Work.claim_task(task, scope), "Task claimed.")
|
||||
end
|
||||
|
||||
def handle_event("release", _params, %{assigns: %{current_scope: scope, task: task}} = socket) do
|
||||
respond(socket, Work.release_task(task, scope), "Claim released.")
|
||||
end
|
||||
|
||||
def handle_event("validate_contribution", %{"contribution" => params}, socket) do
|
||||
form =
|
||||
%Contribution{}
|
||||
|> Work.change_contribution(params)
|
||||
|> Map.put(:action, :validate)
|
||||
|> to_form(as: :contribution)
|
||||
|
||||
{:noreply, assign(socket, :contribution_form, form)}
|
||||
end
|
||||
|
||||
def handle_event(
|
||||
"complete",
|
||||
%{"contribution" => params},
|
||||
%{assigns: %{current_scope: scope, task: task}} = socket
|
||||
) do
|
||||
case Work.submit_task(task, scope, params) do
|
||||
{:ok, task} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign_task(task)
|
||||
|> put_flash(:info, "Evidence submitted for independent review.")}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign(socket, :contribution_form, to_form(changeset, as: :contribution))}
|
||||
|
||||
error ->
|
||||
{:noreply, put_flash(socket, :error, error_message(error))}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event(
|
||||
"complete_verification",
|
||||
%{"verification" => params},
|
||||
%{assigns: %{current_scope: scope, task: task}} = socket
|
||||
) do
|
||||
case Work.submit_task(task, scope, params) do
|
||||
{:ok, task} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign_task(task)
|
||||
|> put_flash(:info, "Finding check submitted.")}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:verification_form, verification_form(task, params))
|
||||
|> put_flash(:error, changeset_error(changeset))}
|
||||
|
||||
error ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:verification_form, verification_form(task, params))
|
||||
|> put_flash(:error, error_message(error))}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event(
|
||||
"publish",
|
||||
%{"decision" => params},
|
||||
%{assigns: %{current_scope: scope, task: task}} = socket
|
||||
) do
|
||||
with_recent_auth(socket, fn ->
|
||||
respond(
|
||||
socket,
|
||||
Work.publish_task(task, scope, params),
|
||||
"Task approved for the public queue."
|
||||
)
|
||||
end)
|
||||
end
|
||||
|
||||
def handle_event(
|
||||
"review",
|
||||
%{"action" => action, "decision" => params},
|
||||
%{assigns: %{current_scope: scope, task: task}} = socket
|
||||
)
|
||||
when action in ["accept", "request_changes", "reject"] do
|
||||
with_recent_auth(socket, fn ->
|
||||
{result, message} =
|
||||
case action do
|
||||
"accept" ->
|
||||
{Work.accept_task(task, scope, params),
|
||||
"Contribution accepted and held for a separate disclosure decision."}
|
||||
|
||||
"request_changes" ->
|
||||
{Work.request_changes(task, scope, params), "Changes requested."}
|
||||
|
||||
"reject" ->
|
||||
{Work.reject_task(task, scope, params), "Contribution rejected."}
|
||||
end
|
||||
|
||||
respond(socket, result, message)
|
||||
end)
|
||||
end
|
||||
|
||||
def handle_event("review", _params, socket) do
|
||||
{:noreply, put_flash(socket, :error, "The requested review action is invalid.")}
|
||||
end
|
||||
|
||||
def handle_event(
|
||||
"disclose",
|
||||
%{"visibility" => visibility, "disclosure" => params},
|
||||
%{assigns: %{current_scope: scope, task: task}} = socket
|
||||
)
|
||||
when visibility in ["public_summary", "public"] do
|
||||
with_recent_auth(socket, fn ->
|
||||
message =
|
||||
if visibility == "public",
|
||||
do: "Full evidence disclosed after sensitive-data review.",
|
||||
else: "A redacted result summary is now public."
|
||||
|
||||
respond_disclosure(
|
||||
socket,
|
||||
Work.disclose_task(task, scope, visibility, params),
|
||||
message,
|
||||
params
|
||||
)
|
||||
end)
|
||||
end
|
||||
|
||||
def handle_event("disclose", _params, socket) do
|
||||
{:noreply, put_flash(socket, :error, "The requested disclosure is invalid.")}
|
||||
end
|
||||
|
||||
def handle_event(
|
||||
"cancel",
|
||||
%{"decision" => params},
|
||||
%{assigns: %{current_scope: scope, task: task}} = socket
|
||||
) do
|
||||
respond(socket, Work.cancel_task(task, scope, params), "Task cancelled.")
|
||||
end
|
||||
|
||||
defp respond(socket, {:ok, task}, message) do
|
||||
{:noreply, socket |> assign_task(task) |> put_flash(:info, message)}
|
||||
end
|
||||
|
||||
defp respond(socket, {:error, %Ecto.Changeset{} = changeset}, _message) do
|
||||
{:noreply, assign(socket, :decision_form, to_form(changeset, as: :decision))}
|
||||
end
|
||||
|
||||
defp respond(socket, error, _message) do
|
||||
{:noreply, put_flash(socket, :error, error_message(error))}
|
||||
end
|
||||
|
||||
defp respond_disclosure(socket, {:ok, task}, message, _params) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign_task(task)
|
||||
|> assign(:disclosure_form, disclosure_form())
|
||||
|> put_flash(:info, message)}
|
||||
end
|
||||
|
||||
defp respond_disclosure(
|
||||
socket,
|
||||
{:error, %Ecto.Changeset{} = changeset},
|
||||
_message,
|
||||
params
|
||||
) do
|
||||
form =
|
||||
params
|
||||
|> Map.take(["reason", "sensitive_data_reviewed"])
|
||||
|> to_form(as: :disclosure, errors: changeset.errors)
|
||||
|
||||
{:noreply, assign(socket, :disclosure_form, form)}
|
||||
end
|
||||
|
||||
defp respond_disclosure(socket, error, _message, _params) do
|
||||
{:noreply, put_flash(socket, :error, error_message(error))}
|
||||
end
|
||||
|
||||
defp assign_task(socket, task) do
|
||||
socket
|
||||
|> assign(:task, task)
|
||||
|> assign(:visible_target_review, visible_target_review(socket.assigns.current_scope, task))
|
||||
|> assign(:provenance_options, provenance_options(task))
|
||||
|> assign(:verification_form, verification_form(task))
|
||||
|> assign(:decision_form, decision_form(task))
|
||||
|> assign(
|
||||
:contribution_form,
|
||||
to_form(
|
||||
Work.change_contribution(%Contribution{}, %{
|
||||
"provenance" => task |> allowed_provenances() |> List.first()
|
||||
}),
|
||||
as: :contribution
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
defp decision_form(%ReviewTask{status: "proposed"} = task) do
|
||||
to_form(%{"reason" => Work.default_publish_reason(task), "evidence" => ""}, as: :decision)
|
||||
end
|
||||
|
||||
defp decision_form(_task) do
|
||||
to_form(%{"reason" => "", "evidence" => ""}, as: :decision)
|
||||
end
|
||||
|
||||
defp disclosure_form do
|
||||
to_form(
|
||||
%{"reason" => "", "sensitive_data_reviewed" => "false"},
|
||||
as: :disclosure
|
||||
)
|
||||
end
|
||||
|
||||
defp verification_form(task, params \\ %{}) do
|
||||
defaults = %{
|
||||
"provenance" => task |> allowed_provenances() |> List.first(),
|
||||
"verdict" => "confirmed",
|
||||
"notes" => "",
|
||||
"evidence" => ""
|
||||
}
|
||||
|
||||
defaults
|
||||
|> Map.merge(params)
|
||||
|> to_form(as: :verification)
|
||||
end
|
||||
|
||||
defp visible_target_review(_scope, %{target_review_id: nil}), do: nil
|
||||
|
||||
defp visible_target_review(scope, %{target_review_id: review_id}) do
|
||||
case Scans.get_scan(scope, review_id) do
|
||||
{:ok, review} -> review
|
||||
{:error, :not_found} -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp changeset_error(%Ecto.Changeset{errors: [{_field, {message, _opts}} | _]}),
|
||||
do: "Verification was not submitted: #{message}."
|
||||
|
||||
defp changeset_error(_changeset), do: "Verification was not submitted."
|
||||
|
||||
defp with_recent_auth(socket, fun) do
|
||||
account = socket.assigns.current_scope && socket.assigns.current_scope.account
|
||||
|
||||
if account && Accounts.sudo_mode?(account) do
|
||||
fun.()
|
||||
else
|
||||
return_to = ~p"/jobs/#{socket.assigns.task.id}"
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(
|
||||
:error,
|
||||
"Confirm it's you with a magic link before changing the public record (sign-in older than 8 hours)."
|
||||
)
|
||||
|> push_navigate(to: TarakanWeb.AccountAuth.reauth_path(return_to))}
|
||||
end
|
||||
end
|
||||
|
||||
defp owns_claim?(task, %{account_id: account_id}) when is_integer(account_id) do
|
||||
task.claimed_by_id == account_id and ReviewTask.claim_active?(task)
|
||||
end
|
||||
|
||||
defp owns_claim?(_task, _scope), do: false
|
||||
|
||||
defp can_claim?(task, scope) do
|
||||
claimable_for_display?(task) and Policy.allowed?(scope, :claim_task, task)
|
||||
end
|
||||
|
||||
defp can_publish?(%{status: "proposed"} = task, %{account_id: account_id} = scope)
|
||||
when is_integer(account_id) do
|
||||
# Stewards/owners and moderators may publish, including their own proposals.
|
||||
Policy.allowed?(scope, :publish_task, task)
|
||||
end
|
||||
|
||||
defp can_publish?(_task, _scope), do: false
|
||||
|
||||
defp can_review?(%{status: "submitted"} = task, %{account_id: account_id} = scope)
|
||||
when is_integer(account_id) do
|
||||
task.created_by_id != account_id and task.claimed_by_id != account_id and
|
||||
not Enum.any?(task.contributions, &(&1.account_id == account_id)) and
|
||||
Policy.allowed?(scope, :review_contribution, task)
|
||||
end
|
||||
|
||||
defp can_review?(_task, _scope), do: false
|
||||
|
||||
defp can_disclose?(%{status: "accepted"} = task, scope) do
|
||||
Policy.allowed?(scope, :disclose_task, task)
|
||||
end
|
||||
|
||||
defp can_disclose?(_task, _scope), do: false
|
||||
|
||||
defp full_disclosure_allowed?(task) do
|
||||
task.repository.participation_mode in ["maintainer_verified", "curated"]
|
||||
end
|
||||
|
||||
defp can_cancel?(task, scope) do
|
||||
task.status in ["proposed", "open", "changes_requested"] and
|
||||
Policy.allowed?(scope, :cancel_task, task)
|
||||
end
|
||||
|
||||
defp created_by_current_account?(task, %{account_id: account_id})
|
||||
when is_integer(account_id),
|
||||
do: task.created_by_id == account_id
|
||||
|
||||
defp created_by_current_account?(_task, _scope), do: false
|
||||
|
||||
defp claimable_for_display?(%{status: "claimed"} = task),
|
||||
do: not ReviewTask.claim_active?(task)
|
||||
|
||||
defp claimable_for_display?(task), do: ReviewTask.claimable?(task)
|
||||
|
||||
defp error_message({:error, :own_task}), do: "That action is not allowed on this job."
|
||||
defp error_message({:error, :not_independent}), do: "An independent reviewer is required."
|
||||
defp error_message({:error, :already_claimed}), do: "This task is already claimed."
|
||||
defp error_message({:error, :claim_limit}), do: "You have reached your active claim limit."
|
||||
defp error_message({:error, :claim_expired}), do: "Your claim expired. Claim the task again."
|
||||
|
||||
defp error_message({:error, :capability_mismatch}),
|
||||
do: "The provenance does not match the task."
|
||||
|
||||
defp error_message({:error, :verdict_required}),
|
||||
do: "Choose confirmed or disputed and provide verification notes."
|
||||
|
||||
defp error_message({:error, :target_review_required}),
|
||||
do: "This check job has no target report."
|
||||
|
||||
defp error_message({:error, :target_review_mismatch}),
|
||||
do: "The selected report does not match this check job."
|
||||
|
||||
defp error_message({:error, :not_claimant}), do: "You do not hold this claim."
|
||||
defp error_message({:error, :closed}), do: "This task is closed."
|
||||
defp error_message({:error, :not_open}), do: "This task is not open for claims."
|
||||
defp error_message({:error, :invalid_state}), do: "That transition is no longer valid."
|
||||
defp error_message({:error, :active_work}), do: "Resolve active work before cancelling."
|
||||
defp error_message({:error, :invalid_visibility}), do: "That disclosure level is invalid."
|
||||
|
||||
defp error_message({:error, :full_disclosure_not_allowed}),
|
||||
do: "Full evidence requires a maintainer-verified or curated repository."
|
||||
|
||||
defp error_message({:error, :sensitive_data_review_required}),
|
||||
do: "Confirm that the evidence was checked for secrets and personal data."
|
||||
|
||||
defp error_message({:error, :identity_changed}),
|
||||
do: "The repository is no longer confirmed public, so this result cannot be disclosed."
|
||||
|
||||
defp error_message({:error, :claim_rate_limited}),
|
||||
do: "Too many claim changes. Wait a minute and try again."
|
||||
|
||||
defp error_message({:error, :unauthorized}), do: "You are not authorized for that action."
|
||||
defp error_message({:error, _reason}), do: "The action could not be completed."
|
||||
|
||||
defp task_meta_description(task) do
|
||||
repo =
|
||||
case task.repository do
|
||||
%{owner: owner, name: name} -> "#{owner}/#{name}"
|
||||
_ -> "open source"
|
||||
end
|
||||
|
||||
kind = task.kind |> to_string() |> String.replace("_", " ")
|
||||
status = task.status |> to_string() |> String.replace("_", " ")
|
||||
|
||||
desc =
|
||||
"Open security job on #{repo}: #{task.title}. " <>
|
||||
"#{String.capitalize(kind)} · #{status}. Claim on Tarakan."
|
||||
|
||||
String.slice(String.replace(desc, ~r/\s+/, " "), 0, 160)
|
||||
end
|
||||
|
||||
defp kind_label("code_review"), do: "Code review"
|
||||
defp kind_label("threat_model"), do: "Threat model"
|
||||
defp kind_label("privacy_review"), do: "Privacy review"
|
||||
defp kind_label("business_logic"), do: "Business logic"
|
||||
defp kind_label("verify_findings"), do: "Verify findings"
|
||||
defp kind_label("write_fix"), do: "Write a fix"
|
||||
|
||||
defp provenance_options(task) do
|
||||
Enum.map(allowed_provenances(task), &{provenance_label(&1), &1})
|
||||
end
|
||||
|
||||
defp agent_primary_path?(%{capability: "agent"}), do: true
|
||||
defp agent_primary_path?(_task), do: false
|
||||
|
||||
defp allowed_provenances(%{capability: "human"}), do: ["human", "hybrid"]
|
||||
defp allowed_provenances(%{capability: "agent"}), do: ["agent", "hybrid"]
|
||||
defp allowed_provenances(%{capability: "hybrid"}), do: ["hybrid"]
|
||||
|
||||
defp short_sha(sha), do: String.slice(sha, 0, 7)
|
||||
|
||||
defp task_status(%{status: "claimed"} = task) do
|
||||
if ReviewTask.claim_active?(task), do: "Claimed", else: "Open"
|
||||
end
|
||||
|
||||
defp task_status(%{status: "changes_requested"}), do: "Changes requested"
|
||||
defp task_status(%{status: status}), do: String.capitalize(status)
|
||||
|
||||
defp visibility_label("restricted"), do: "Restricted"
|
||||
defp visibility_label("public_summary"), do: "Public summary"
|
||||
defp visibility_label("public"), do: "Full evidence public"
|
||||
|
||||
defp repository_path(task),
|
||||
do: TarakanWeb.RepositoryPaths.repository_path(task.repository)
|
||||
end
|
||||
576
lib/tarakan_web/live/review_task_live/show.html.heex
Normal file
576
lib/tarakan_web/live/review_task_live/show.html.heex
Normal file
|
|
@ -0,0 +1,576 @@
|
|||
<Layouts.app flash={@flash} current_scope={@current_scope} current_path={assigns[:current_path]}>
|
||||
<Layouts.page>
|
||||
<.breadcrumbs>
|
||||
<:crumb navigate={~p"/"}>registry</:crumb>
|
||||
<:crumb navigate={TarakanWeb.RepositoryPaths.repository_path(@task.repository)}>
|
||||
{@task.repository.owner}/{@task.repository.name}
|
||||
</:crumb>
|
||||
<:crumb>job/{@task.id}</:crumb>
|
||||
</.breadcrumbs>
|
||||
|
||||
<main id="review-task" class="border-2 border-strong">
|
||||
<header class="border-b-2 border-strong bg-panel px-5 py-5 sm:px-8 sm:py-6">
|
||||
<div class="flex flex-wrap items-center gap-2 font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint">
|
||||
<span id="review-task-status" class="border border-rule px-2 py-1 text-ink-muted">
|
||||
{task_status(@task)}
|
||||
</span>
|
||||
<span
|
||||
:if={@task.linked_review_id && @task.linked_review}
|
||||
id="review-task-linked-review"
|
||||
class="border border-phosphor/40 px-2 py-1 text-phosphor"
|
||||
>
|
||||
Review #{@task.linked_review.id} · {@task.linked_review.findings_count} findings · {@task.linked_review.review_status}
|
||||
</span>
|
||||
<span
|
||||
:if={
|
||||
@task.status in ["submitted", "accepted"] and is_nil(@task.linked_review_id) and
|
||||
@task.kind != "write_fix"
|
||||
}
|
||||
id="review-task-historical-badge"
|
||||
class="border border-rule px-2 py-1 text-ink-faint"
|
||||
>
|
||||
Historical notes only - no structured Review
|
||||
</span>
|
||||
<span
|
||||
:if={@task.status in ["submitted", "accepted"] and @task.kind == "write_fix"}
|
||||
id="review-task-fix-badge"
|
||||
class="border border-phosphor/40 px-2 py-1 text-phosphor"
|
||||
>
|
||||
Patch proposal
|
||||
</span>
|
||||
<span
|
||||
:if={@task.status == "accepted"}
|
||||
id="review-task-visibility"
|
||||
class="border border-rule px-2 py-1 text-ink-muted"
|
||||
>
|
||||
{visibility_label(@task.visibility)}
|
||||
</span>
|
||||
<span>{kind_label(@task.kind)}</span>
|
||||
<span>·</span>
|
||||
<span>{provenance_label(@task.capability)} required</span>
|
||||
<span>·</span>
|
||||
<span title={@task.commit_sha}>{short_sha(@task.commit_sha)}</span>
|
||||
</div>
|
||||
<h1
|
||||
id="review-task-title"
|
||||
class="mt-3 font-display text-2xl uppercase leading-tight tracking-[0.02em] text-ink sm:text-3xl"
|
||||
>
|
||||
{@task.title}
|
||||
</h1>
|
||||
<p
|
||||
:if={String.trim(@task.description || "") != ""}
|
||||
class="mt-3 max-w-3xl whitespace-pre-line text-sm leading-6 text-ink-muted"
|
||||
phx-no-format
|
||||
>{String.trim(@task.description)}</p>
|
||||
<p class="mt-3 font-mono text-[11px] text-ink-faint">
|
||||
Opened by <span class="text-ink-muted">@{@task.created_by.handle}</span>
|
||||
· commit <span title={@task.commit_sha}>{short_sha(@task.commit_sha)}</span>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section
|
||||
:if={@task.kind == "verify_findings"}
|
||||
id="review-task-target-report"
|
||||
class="border-b border-rule bg-ground px-5 py-5 sm:px-8"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2 font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint">
|
||||
<span>Target report #{@task.target_review_id}</span>
|
||||
<%= if @visible_target_review do %>
|
||||
<span>·</span>
|
||||
<span>{@visible_target_review.findings_count} findings</span>
|
||||
<span>·</span>
|
||||
<span>{@visible_target_review.review_status}</span>
|
||||
<span>·</span>
|
||||
<span title={@visible_target_review.commit_sha}>{short_sha(
|
||||
@visible_target_review.commit_sha
|
||||
)}</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<p class="mt-2 text-sm leading-6 text-ink-muted">
|
||||
Reproduce or dispute every visible finding against the target report's exact commit.
|
||||
</p>
|
||||
<ul
|
||||
:if={@visible_target_review && @visible_target_review.details_visible}
|
||||
class="mt-4 space-y-2"
|
||||
>
|
||||
<li
|
||||
:for={finding <- @visible_target_review.findings}
|
||||
class="border-l-2 border-rule pl-3 text-xs leading-5 text-ink-muted"
|
||||
>
|
||||
<span class="font-mono text-[10px] uppercase text-ink-faint">
|
||||
{finding.severity} · {finding.file_path}:{finding.line_start}
|
||||
</span>
|
||||
<span class="ml-2 font-semibold text-ink">{finding.title}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p
|
||||
:if={is_nil(@visible_target_review) || !@visible_target_review.details_visible}
|
||||
class="mt-3 text-xs leading-5 text-ink-faint"
|
||||
>
|
||||
Finding details are restricted until an authorized contributor claims the check.
|
||||
</p>
|
||||
<div
|
||||
:if={@visible_target_review && @visible_target_review.confirmations != []}
|
||||
id="review-task-target-confirmations"
|
||||
class="mt-5 border-t border-rule pt-4"
|
||||
>
|
||||
<h3 class="text-sm font-semibold text-ink">
|
||||
Recorded checks
|
||||
</h3>
|
||||
<article
|
||||
:for={confirmation <- @visible_target_review.confirmations}
|
||||
class="mt-3 border-l-2 border-phosphor pl-4"
|
||||
>
|
||||
<p class="font-mono text-[11px] text-ink-muted">
|
||||
{confirmation.verdict} · {provenance_label(confirmation.provenance)}
|
||||
<span :if={confirmation.account}> · @{confirmation.account.handle}</span>
|
||||
</p>
|
||||
<p
|
||||
:if={confirmation.notes}
|
||||
class="mt-2 whitespace-pre-line text-xs leading-5 text-ink-muted"
|
||||
phx-no-format
|
||||
>{confirmation.notes}</p>
|
||||
<pre
|
||||
:if={confirmation.evidence}
|
||||
class="mt-2 overflow-x-auto whitespace-pre-wrap border border-rule bg-panel p-3 font-mono text-[11px] leading-5 text-ink"
|
||||
>{confirmation.evidence}</pre>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
:if={@task.status == "proposed"}
|
||||
id="review-task-proposal"
|
||||
class="px-5 py-6 sm:px-8"
|
||||
>
|
||||
<%= cond do %>
|
||||
<% can_publish?(@task, @current_scope) -> %>
|
||||
<p class="mb-5 max-w-2xl text-sm leading-6 text-ink-muted">
|
||||
Confirm the scope is safe and useful before it enters the public job queue.
|
||||
Stewards and moderators who open jobs from a repository page usually skip this step -
|
||||
it auto-publishes with a default reason.
|
||||
</p>
|
||||
<.form for={@decision_form} id="publish-review-task-form" phx-submit="publish">
|
||||
<.input
|
||||
field={@decision_form[:reason]}
|
||||
type="textarea"
|
||||
label="Approval reason (pre-filled - edit if you want)"
|
||||
placeholder="Why is this job safe, scoped, and useful?"
|
||||
/>
|
||||
<button
|
||||
id="publish-review-task-button"
|
||||
type="submit"
|
||||
class="clip-notch mt-5 bg-btn px-5 py-2.5 font-display text-xs uppercase tracking-[0.12em] text-btn-fg transition hover:opacity-90"
|
||||
>
|
||||
Publish job
|
||||
</button>
|
||||
</.form>
|
||||
<% created_by_current_account?(@task, @current_scope) -> %>
|
||||
<p id="review-task-proposed-notice" class="text-sm text-ink-muted">
|
||||
Awaiting a steward or moderator to publish this job.
|
||||
</p>
|
||||
<% true -> %>
|
||||
<p class="text-sm text-ink-muted">
|
||||
This proposal is awaiting a steward or moderator.
|
||||
</p>
|
||||
<% end %>
|
||||
</section>
|
||||
|
||||
<section
|
||||
:if={@task.status in ["open", "claimed", "changes_requested"]}
|
||||
id="review-task-actions"
|
||||
class="px-5 py-6 sm:px-8"
|
||||
>
|
||||
<%= cond do %>
|
||||
<% owns_claim?(@task, @current_scope) -> %>
|
||||
<div class="mb-6 flex flex-wrap items-center justify-between gap-3 border-b border-rule pb-5">
|
||||
<p class="text-sm text-ink-muted">
|
||||
You hold this job until <span class="font-mono text-ink">{Calendar.strftime(@task.claim_expires_at, "%H:%M UTC")}</span>.
|
||||
</p>
|
||||
<button
|
||||
id="release-review-task-button"
|
||||
phx-click="release"
|
||||
class="font-mono text-xs text-ink-faint transition hover:text-signal"
|
||||
>
|
||||
Release claim
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<%= cond do %>
|
||||
<% @task.kind == "verify_findings" and agent_primary_path?(@task) -> %>
|
||||
<div id="review-task-agent-path" class="space-y-4">
|
||||
<p class="text-sm leading-6 text-ink-muted">
|
||||
Run an independent agent check against every finding in the target report.
|
||||
</p>
|
||||
<div class="border border-strong bg-panel px-4 py-4 font-mono text-xs leading-6 text-ink">
|
||||
<p class="text-xs font-semibold text-ink-muted">CLI</p>
|
||||
<pre class="mt-2 overflow-x-auto whitespace-pre-wrap">tarakan worker --agent grok --once --jobs-only</pre>
|
||||
</div>
|
||||
</div>
|
||||
<% @task.kind == "write_fix" and agent_primary_path?(@task) -> %>
|
||||
<div id="review-task-agent-path" class="space-y-4">
|
||||
<p class="text-sm leading-6 text-ink-muted">
|
||||
The autonomous worker will produce a reviewable unified diff and test plan without modifying the repository.
|
||||
</p>
|
||||
<div class="border border-strong bg-panel px-4 py-4 font-mono text-xs leading-6 text-ink">
|
||||
<p class="text-xs font-semibold text-ink-muted">CLI</p>
|
||||
<pre class="mt-2 overflow-x-auto whitespace-pre-wrap">tarakan worker --agent grok --once --jobs-only</pre>
|
||||
</div>
|
||||
</div>
|
||||
<% agent_primary_path?(@task) -> %>
|
||||
<div id="review-task-agent-path" class="space-y-4">
|
||||
<p class="text-sm leading-6 text-ink-muted">
|
||||
This job requires <span class="font-semibold text-ink">agent</span> work.
|
||||
</p>
|
||||
<div class="border border-strong bg-panel px-4 py-4 font-mono text-xs leading-6 text-ink">
|
||||
<p class="text-xs font-semibold text-ink-muted">CLI</p>
|
||||
<pre class="mt-2 overflow-x-auto whitespace-pre-wrap">tarakan report --agent grok --job {@task.id} --yes
|
||||
# or: tarakan report --agent grok --job {@task.id} --interactive</pre>
|
||||
</div>
|
||||
<p class="text-xs leading-5 text-ink-faint">
|
||||
Agents: <span class="font-mono text-ink">grok</span>, <span class="font-mono text-ink">codex</span>, <span class="font-mono text-ink">claude</span>, <span class="font-mono text-ink">kimi</span>.
|
||||
</p>
|
||||
</div>
|
||||
<% @task.kind == "verify_findings" -> %>
|
||||
<.form
|
||||
for={@verification_form}
|
||||
id="review-task-verification-form"
|
||||
phx-submit="complete_verification"
|
||||
>
|
||||
<div class="grid gap-4">
|
||||
<.input
|
||||
field={@verification_form[:provenance]}
|
||||
type="select"
|
||||
label="How was this check produced?"
|
||||
options={@provenance_options}
|
||||
/>
|
||||
<.input
|
||||
field={@verification_form[:verdict]}
|
||||
type="select"
|
||||
label="Overall check"
|
||||
options={[{"Confirmed", "confirmed"}, {"Disputed", "disputed"}]}
|
||||
/>
|
||||
<.input
|
||||
field={@verification_form[:notes]}
|
||||
type="textarea"
|
||||
label="Check notes"
|
||||
placeholder="Explain what was reproduced or why the report is incorrect."
|
||||
/>
|
||||
<.input
|
||||
field={@verification_form[:evidence]}
|
||||
type="textarea"
|
||||
label="Proof of concept or counter-evidence"
|
||||
placeholder="Commands, exact traces, observed output, and relevant file:line references"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
id="complete-review-task-button"
|
||||
type="submit"
|
||||
class="clip-notch mt-5 bg-btn px-5 py-2.5 font-display text-xs uppercase tracking-[0.12em] text-btn-fg transition hover:opacity-90 phx-submit-loading:opacity-60"
|
||||
>
|
||||
Submit check
|
||||
</button>
|
||||
</.form>
|
||||
<% true -> %>
|
||||
<.form
|
||||
for={@contribution_form}
|
||||
id="review-task-completion-form"
|
||||
phx-change="validate_contribution"
|
||||
phx-submit="complete"
|
||||
>
|
||||
<div class="grid gap-4">
|
||||
<.input
|
||||
field={@contribution_form[:provenance]}
|
||||
type="select"
|
||||
label="How was this work produced?"
|
||||
options={@provenance_options}
|
||||
/>
|
||||
<.input
|
||||
field={@contribution_form[:summary]}
|
||||
type="textarea"
|
||||
label={
|
||||
if @task.kind == "write_fix", do: "Fix summary", else: "Result summary"
|
||||
}
|
||||
placeholder={
|
||||
if @task.kind == "write_fix",
|
||||
do: "What does the proposed patch change and why?",
|
||||
else: "What did you establish, reproduce, or refute?"
|
||||
}
|
||||
/>
|
||||
<.input
|
||||
field={@contribution_form[:evidence]}
|
||||
type="textarea"
|
||||
label={
|
||||
if @task.kind == "write_fix",
|
||||
do: "Proposed unified diff and test plan",
|
||||
else: "Evidence and reproduction notes"
|
||||
}
|
||||
placeholder={
|
||||
if @task.kind == "write_fix",
|
||||
do: "Paste a unified diff followed by tests that should prove the fix",
|
||||
else:
|
||||
"File paths, test commands, observations, and remaining uncertainty"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
id="complete-review-task-button"
|
||||
type="submit"
|
||||
class="clip-notch mt-5 bg-btn px-5 py-2.5 font-display text-xs uppercase tracking-[0.12em] text-btn-fg transition hover:opacity-90 phx-submit-loading:opacity-60"
|
||||
>
|
||||
Submit job result
|
||||
</button>
|
||||
</.form>
|
||||
<% end %>
|
||||
<% can_claim?(@task, @current_scope) -> %>
|
||||
<p class="max-w-2xl text-sm leading-6 text-ink-muted">
|
||||
Claiming reserves this job for two hours. Creators may claim and perform their own Jobs.
|
||||
</p>
|
||||
<button
|
||||
id="claim-review-task-button"
|
||||
phx-click="claim"
|
||||
class="clip-notch mt-4 bg-btn px-5 py-2.5 font-display text-xs uppercase tracking-[0.12em] text-btn-fg transition hover:opacity-90"
|
||||
>
|
||||
Claim job
|
||||
</button>
|
||||
<% @task.claimed_by && ReviewTask.claim_active?(@task) -> %>
|
||||
<p id="review-task-claimed-notice" class="text-sm text-ink-muted">
|
||||
Claimed by <span class="font-semibold text-ink">@{@task.claimed_by.handle}</span>.
|
||||
</p>
|
||||
<% true -> %>
|
||||
<.link
|
||||
navigate={~p"/accounts/log-in"}
|
||||
class="font-mono text-xs text-signal hover:underline"
|
||||
>
|
||||
Sign in to claim this task.
|
||||
</.link>
|
||||
<% end %>
|
||||
</section>
|
||||
|
||||
<section
|
||||
:if={@task.status == "submitted"}
|
||||
id="review-task-review"
|
||||
class="border-t border-rule px-5 py-6 sm:px-8"
|
||||
>
|
||||
<%= if can_review?(@task, @current_scope) do %>
|
||||
<h2 class="font-display text-xl uppercase tracking-[0.02em] text-ink">
|
||||
Independent review
|
||||
</h2>
|
||||
<.form
|
||||
for={@decision_form}
|
||||
id="review-task-decision-form"
|
||||
phx-submit="review"
|
||||
class="mt-5"
|
||||
>
|
||||
<div class="grid gap-4">
|
||||
<.input field={@decision_form[:reason]} type="textarea" label="Decision reason" />
|
||||
<.input
|
||||
field={@decision_form[:evidence]}
|
||||
type="textarea"
|
||||
label="Independent evidence"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-5 flex flex-wrap gap-3">
|
||||
<button
|
||||
id="accept-review-task-button"
|
||||
type="submit"
|
||||
name="action"
|
||||
value="accept"
|
||||
class="clip-notch bg-btn px-4 py-2 font-display text-xs uppercase tracking-[0.12em] text-btn-fg"
|
||||
>
|
||||
Close request
|
||||
</button>
|
||||
<button
|
||||
id="request-changes-review-task-button"
|
||||
type="submit"
|
||||
name="action"
|
||||
value="request_changes"
|
||||
class="border border-rule px-4 py-2 font-display text-xs uppercase tracking-[0.12em] text-ink"
|
||||
>
|
||||
Request changes
|
||||
</button>
|
||||
<button
|
||||
id="reject-review-task-button"
|
||||
type="submit"
|
||||
name="action"
|
||||
value="reject"
|
||||
class="border border-signal px-4 py-2 font-display text-xs uppercase tracking-[0.12em] text-signal"
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
</.form>
|
||||
<% else %>
|
||||
<p id="review-task-review-pending" class="text-sm text-ink-muted">
|
||||
Evidence submitted. Awaiting independent review.
|
||||
</p>
|
||||
<% end %>
|
||||
</section>
|
||||
|
||||
<section
|
||||
:if={@task.status == "accepted"}
|
||||
id="review-task-disclosure"
|
||||
class="border-t border-rule px-5 py-6 sm:px-8"
|
||||
>
|
||||
<%= if can_disclose?(@task, @current_scope) do %>
|
||||
<h2 class="font-display text-xl uppercase tracking-[0.02em] text-ink">
|
||||
Disclosure decision
|
||||
</h2>
|
||||
<.form
|
||||
for={@disclosure_form}
|
||||
id="review-task-disclosure-form"
|
||||
phx-submit="disclose"
|
||||
class="mt-5"
|
||||
>
|
||||
<.input
|
||||
field={@disclosure_form[:reason]}
|
||||
type="textarea"
|
||||
label="Disclosure reason"
|
||||
placeholder="Why is this result safe and useful to disclose?"
|
||||
/>
|
||||
<div :if={full_disclosure_allowed?(@task)} class="mt-4">
|
||||
<.input
|
||||
field={@disclosure_form[:sensitive_data_reviewed]}
|
||||
type="checkbox"
|
||||
label="I checked the evidence for secrets, credentials, and personal data"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-5 flex flex-wrap gap-3">
|
||||
<button
|
||||
id="publish-review-task-summary-button"
|
||||
type="submit"
|
||||
name="visibility"
|
||||
value="public_summary"
|
||||
class="clip-notch bg-btn px-4 py-2 font-display text-xs uppercase tracking-[0.12em] text-btn-fg"
|
||||
>
|
||||
Publish summary
|
||||
</button>
|
||||
<button
|
||||
:if={full_disclosure_allowed?(@task)}
|
||||
id="publish-review-task-full-button"
|
||||
type="submit"
|
||||
name="visibility"
|
||||
value="public"
|
||||
class="border border-rule px-4 py-2 font-display text-xs uppercase tracking-[0.12em] text-ink"
|
||||
>
|
||||
Publish full evidence
|
||||
</button>
|
||||
</div>
|
||||
</.form>
|
||||
<% else %>
|
||||
<p id="review-task-disclosure-pending" class="text-sm text-ink-muted">
|
||||
<%= if @task.visibility == "restricted" do %>
|
||||
Accepted. Awaiting a disclosure decision.
|
||||
<% else %>
|
||||
This result has an attributable disclosure decision.
|
||||
<% end %>
|
||||
</p>
|
||||
<% end %>
|
||||
</section>
|
||||
|
||||
<section
|
||||
:if={@task.linked_review}
|
||||
id="review-task-linked-review-detail"
|
||||
class="border-t border-rule px-5 py-6 sm:px-8 sm:py-8"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2 font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint">
|
||||
<span>Linked Review #{@task.linked_review.id}</span>
|
||||
<span>·</span>
|
||||
<span>{@task.linked_review.review_status}</span>
|
||||
<span>·</span>
|
||||
<span>{provenance_label(@task.linked_review.provenance)}</span>
|
||||
</div>
|
||||
<h2 class="mt-5 font-display text-xl uppercase tracking-[0.02em] text-ink">
|
||||
Structured findings
|
||||
</h2>
|
||||
<p class="mt-2 text-sm text-ink-muted">
|
||||
Request submitted. The linked report remains separately {@task.linked_review.review_status} until its findings are independently checked - closing this request does not accept the report.
|
||||
</p>
|
||||
<ul class="mt-5 space-y-3">
|
||||
<li
|
||||
:for={finding <- @task.linked_review.findings || []}
|
||||
class="border border-rule px-4 py-3"
|
||||
>
|
||||
<div class="font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint">
|
||||
{finding.severity} · {finding.file_path}:{finding.line_start}
|
||||
</div>
|
||||
<div class="mt-1 text-sm font-semibold text-ink">{finding.title}</div>
|
||||
<p class="mt-1 text-xs leading-5 text-ink-muted">{finding.description}</p>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section
|
||||
:if={@task.contribution}
|
||||
id="review-task-contribution"
|
||||
class="border-t border-rule px-5 py-6 sm:px-8 sm:py-8"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2 font-mono text-[10px] uppercase tracking-[0.12em] text-ink-faint">
|
||||
<span>
|
||||
<%= cond do %>
|
||||
<% @task.kind == "write_fix" -> %>
|
||||
Patch proposal v{@task.contribution.version} by @{@task.contribution.account.handle}
|
||||
<% @task.linked_review_id -> %>
|
||||
Notes v{@task.contribution.version} by @{@task.contribution.account.handle}
|
||||
<% true -> %>
|
||||
Historical notes v{@task.contribution.version} by @{@task.contribution.account.handle}
|
||||
<% end %>
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span>{provenance_label(@task.contribution.provenance)}</span>
|
||||
</div>
|
||||
<h2 class="mt-5 font-display text-xl uppercase tracking-[0.02em] text-ink">
|
||||
<%= cond do %>
|
||||
<% @task.kind == "write_fix" -> %>
|
||||
Proposed fix
|
||||
<% @task.linked_review_id -> %>
|
||||
Result
|
||||
<% true -> %>
|
||||
Historical notes
|
||||
<% end %>
|
||||
</h2>
|
||||
<p
|
||||
:if={is_nil(@task.linked_review_id) and @task.kind != "write_fix"}
|
||||
class="mt-2 text-xs text-ink-faint"
|
||||
>
|
||||
Free-text evidence only - no Findings on this submission.
|
||||
</p>
|
||||
<p :if={@task.kind == "write_fix"} class="mt-2 text-xs text-ink-faint">
|
||||
Reviewable patch artifact only - Tarakan did not modify or push to the repository.
|
||||
</p>
|
||||
<p class="mt-3 whitespace-pre-line text-sm leading-6 text-ink-muted" phx-no-format>{@task.contribution.summary}</p>
|
||||
<div :if={@task.contribution.evidence} class="mt-6 border-l-2 border-rule pl-5">
|
||||
<h3 class="text-sm font-semibold text-ink">
|
||||
{if @task.kind == "write_fix", do: "Proposed patch and tests", else: "Evidence"}
|
||||
</h3>
|
||||
<pre
|
||||
class="mt-2 overflow-x-auto whitespace-pre-wrap font-mono text-xs leading-6 text-ink-muted"
|
||||
phx-no-format
|
||||
>{@task.contribution.evidence}</pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
:if={can_cancel?(@task, @current_scope)}
|
||||
id="review-task-cancellation"
|
||||
class="border-t border-rule px-5 py-6 sm:px-8"
|
||||
>
|
||||
<.form for={@decision_form} id="cancel-review-task-form" phx-submit="cancel">
|
||||
<.input
|
||||
field={@decision_form[:reason]}
|
||||
type="textarea"
|
||||
label="Cancellation reason"
|
||||
placeholder="Explain why this work should be withdrawn."
|
||||
/>
|
||||
<button
|
||||
id="cancel-review-task-button"
|
||||
type="submit"
|
||||
class="mt-4 font-mono text-xs text-signal hover:underline"
|
||||
>
|
||||
Cancel task
|
||||
</button>
|
||||
</.form>
|
||||
</section>
|
||||
</main>
|
||||
</Layouts.page>
|
||||
</Layouts.app>
|
||||
Loading…
Add table
Add a link
Reference in a new issue