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