Initial commit on Forgejo
All checks were successful
CI and deploy / Test (push) Successful in 4m52s
CI and deploy / Deploy production (push) Successful in 23s

Fresh repository history for elektrine/tarakan hosted at
https://git.elektrine.com/elektrine/tarakan.
This commit is contained in:
Maxfield Luke 2026-07-29 04:43:40 -04:00
commit af6077b9c3
455 changed files with 78366 additions and 0 deletions

View 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

View 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>

View 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

View 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>