prop/lib/microwaveprop_web/controllers/user_settings_html/edit.html.heex
Graham McIntire 04f9ed5fe6
feat(settings): manage /api/v1 tokens from user settings UI
Adds a separate API-token section under /users/settings (distinct from
the beacon-monitor token list, since API tokens grant full account
access). The plaintext is surfaced exactly once via flash on creation;
only the SHA-256 hash is persisted, so revocation is the only path back
if the user loses it.

Also fixes the openapi.yaml link on /docs/api: the relative path
resolved to /docs/openapi.yaml from a no-trailing-slash URL and 404'd.
2026-05-09 09:59:56 -05:00

273 lines
8.4 KiB
Text

<Layouts.app flash={@flash} current_scope={@current_scope}>
<div class="text-center">
<.header>
Account Settings
<:subtitle>Manage your account email address and password settings</:subtitle>
</.header>
</div>
<.form :let={f} for={@email_changeset} action={~p"/users/settings"} id="update_email">
<input type="hidden" name="action" value="update_email" />
<.input
field={f[:email]}
type="email"
label="Email"
autocomplete="username"
spellcheck="false"
required
/>
<.button variant="primary" phx-disable-with="Changing...">Change Email</.button>
</.form>
<div class="divider" />
<.form :let={f} for={@home_qth_changeset} action={~p"/users/settings"} id="update_home_qth">
<input type="hidden" name="action" value="update_home_qth" />
<div class="text-left mb-2">
<h3 class="font-semibold">Home QTH</h3>
<p class="text-xs opacity-70">
Used to anchor the rover map (drive radius, distance/bearing labels).
Enter a Maidenhead grid (e.g. <code>EM13qc</code>) or explicit lat/lon.
</p>
<div
:if={!@current_scope.user.home_grid}
class="alert alert-warning text-xs mt-2"
>
<.icon name="hero-exclamation-triangle" class="size-4 shrink-0" />
<span>
Using the default NTMS-area location (EM13).
A QRZ lookup for <code>{@current_scope.user.callsign}</code>
is queued — check back shortly, or set yours manually below.
</span>
</div>
</div>
<.input
field={f[:home_grid]}
type="text"
label="Home grid"
placeholder="EM13qc"
autocomplete="off"
/>
<div class="grid grid-cols-2 gap-3">
<.input field={f[:home_lat]} type="number" step="any" label="Latitude" />
<.input field={f[:home_lon]} type="number" step="any" label="Longitude" />
</div>
<.button variant="primary" phx-disable-with="Saving...">Save Home QTH</.button>
</.form>
<div class="divider" />
<.form :let={f} for={@password_changeset} action={~p"/users/settings"} id="update_password">
<input type="hidden" name="action" value="update_password" />
<.input
field={f[:password]}
type="password"
label="New password"
autocomplete="new-password"
spellcheck="false"
required
/>
<.input
field={f[:password_confirmation]}
type="password"
label="Confirm new password"
autocomplete="new-password"
spellcheck="false"
required
/>
<.button variant="primary" phx-disable-with="Changing...">
Save Password
</.button>
</.form>
<div class="divider" />
<section id="beacon-monitors" class="space-y-4">
<.header>
Beacon monitors
<:subtitle>
Register remote monitor stations. Each monitor gets a unique token the
monitor program uses to authenticate its reports.
</:subtitle>
</.header>
<div class="alert alert-warning text-sm">
<.icon name="hero-exclamation-triangle" class="size-4" />
<span>Not working yet — monitor client + ingestion pipeline still in development.</span>
</div>
<.form
:let={f}
for={@beacon_monitor_changeset}
as={:beacon_monitor}
action={~p"/users/beacon-monitors"}
id="create_beacon_monitor"
>
<label for={f[:name].id} class="label mb-1">Monitor name</label>
<div class="flex items-center gap-2">
<input
type="text"
name={f[:name].name}
id={f[:name].id}
value={Phoenix.HTML.Form.normalize_value("text", f[:name].value)}
placeholder="e.g. Shack Pi"
class="input flex-1"
required
/>
<.button variant="primary" phx-disable-with="Adding...">Add monitor</.button>
</div>
</.form>
<%= if @beacon_monitors == [] do %>
<p class="text-sm opacity-70">No monitors registered yet.</p>
<% else %>
<div class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr>
<th>Name</th>
<th>Token</th>
<th>Last seen</th>
<th class="w-1"></th>
</tr>
</thead>
<tbody>
<%= for monitor <- @beacon_monitors do %>
<tr>
<td class="font-semibold">{monitor.name}</td>
<td>
<code class="text-xs break-all select-all">{monitor.token}</code>
</td>
<td class="text-sm opacity-70">
<%= if monitor.last_seen_at do %>
{Calendar.strftime(monitor.last_seen_at, "%Y-%m-%d %H:%M UTC")}
<% else %>
never
<% end %>
</td>
<td>
<.link
href={~p"/users/beacon-monitors/#{monitor.id}"}
method="delete"
class="btn btn-ghost btn-xs text-error"
data-confirm={"Delete monitor '#{monitor.name}'? Its token will stop working immediately."}
>
Delete
</.link>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
</section>
<div class="divider" />
<section id="api-tokens" class="space-y-4">
<.header>
API tokens
<:subtitle>
Long-lived bearer tokens for the
<.link navigate={~p"/docs/api"} class="link">REST API</.link>
at <code>/api/v1</code>. Each token grants the same
permissions as your account; treat them like passwords.
</:subtitle>
</.header>
<%= if Phoenix.Flash.get(@flash, :api_token) do %>
<div class="alert alert-success text-sm">
<.icon name="hero-key" class="size-4 shrink-0" />
<div class="flex-1">
<p class="font-semibold mb-1">
New token — copy now, it will not be shown again:
</p>
<code class="text-xs break-all select-all block bg-base-200 rounded p-2">
{Phoenix.Flash.get(@flash, :api_token)}
</code>
</div>
</div>
<% end %>
<.form
:let={f}
for={%{}}
as={:api_token}
action={~p"/users/api-tokens"}
id="create_api_token"
>
<label for={f[:name].id} class="label mb-1">Token name</label>
<div class="flex items-center gap-2">
<input
type="text"
name={f[:name].name}
id={f[:name].id}
placeholder="e.g. laptop, scripts, monitor box"
class="input flex-1"
required
/>
<.button variant="primary" phx-disable-with="Creating...">Create token</.button>
</div>
</.form>
<%= if @api_tokens == [] do %>
<p class="text-sm opacity-70">No API tokens yet.</p>
<% else %>
<div class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr>
<th>Name</th>
<th>Created</th>
<th>Last used</th>
<th>Expires</th>
<th class="w-1"></th>
</tr>
</thead>
<tbody>
<%= for token <- @api_tokens do %>
<tr>
<td class="font-semibold">{token.name}</td>
<td class="text-sm opacity-70">
{Calendar.strftime(token.inserted_at, "%Y-%m-%d")}
</td>
<td class="text-sm opacity-70">
<%= if token.last_used_at do %>
{Calendar.strftime(token.last_used_at, "%Y-%m-%d %H:%M UTC")}
<% else %>
never
<% end %>
</td>
<td class="text-sm opacity-70">
<%= if token.expires_at do %>
{Calendar.strftime(token.expires_at, "%Y-%m-%d")}
<% else %>
never
<% end %>
</td>
<td>
<.link
href={~p"/users/api-tokens/#{token.id}"}
method="delete"
class="btn btn-ghost btn-xs text-error"
data-confirm={"Revoke API token '#{token.name}'? It will stop working immediately."}
>
Revoke
</.link>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
</section>
</Layouts.app>