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
|
||||
Loading…
Add table
Add a link
Reference in a new issue