rustango 0.60.0

Batteries-included web framework for Rust: ORM + migrations + auto-admin + multi-tenancy + audit log + auth (sessions, JWT, OAuth2/OIDC, HMAC) + APIs (ViewSet, OpenAPI auto-derive, JSON:API) + jobs (in-mem + Postgres) + email + media (S3 / R2 / B2 / MinIO + presigned uploads + collections + tags) + production middleware (CSRF, CSP, rate-limiting, compression, idempotency, etc.).
Documentation
{% extends "op_layout.html" %}
{% block title %}Edit {{ slug }} — {{ brand_name | default(value='Rustango Operator Console') }}{% endblock %}
{% block content %}

<nav class="breadcrumb" aria-label="breadcrumb">
  <a href="/orgs">Organizations</a>
  <span class="sep">›</span>
  <code>{{ slug }}</code>
  <span class="sep">›</span>
  edit
</nav>

<h1>Edit <code>{{ slug }}</code></h1>
<p class="lead">
  Adjust the live config for this tenant. Locked fields stay where they
  are — re-provisioning needs <code>cargo run -- create-tenant</code> /
  <code>drop-tenant</code>. Inputs are generated from
  <code>Org::SCHEMA</code> via
  <code>rustango::admin::render::render_input</code>, so new columns
  added to <code>Org</code> show up here without template edits.
</p>

{% if error %}<div class="alert alert-error">{{ error }}</div>{% endif %}
{% if notice %}<div class="alert alert-notice">{{ notice }}</div>{% endif %}

{#- `host_pattern` below is the tenant's *base* host, and it is one
    field. The extra hostnames it can also answer on are a collection
    with their own verbs, so they live on their own page — which needs
    a link from here, or nothing reaches it. -#}
{#- A div, not a p — see op_orgs.html: a `<form>` closes an open `<p>`. -#}
<div class="page-actions">
  <a href="/orgs/{{ slug }}/hosts" class="btn btn-secondary">Hostnames →</a>
  <button type="button" class="btn btn-secondary" id="test-connection">Test connection</button>
  <span id="probe-result"></span>
  {% if provisioning_enabled %}
  <form method="post" action="/orgs/{{ slug }}/migrate" class="inline-form"
        data-confirm="Run migrations for {{ slug }}?">
    {{ csrf_input | default(value='') | safe }}
    <button type="submit" class="btn btn-secondary">Run migrations</button>
  </form>
  {% endif %}
</div>

<form method="post" action="/orgs/{{ slug }}/edit" class="edit-form">
  {{ csrf_input | default(value='') | safe }}

  <fieldset class="section locked">
    <legend>Locked</legend>
    <table>
      {% for row in locked_rows %}
      <tr>
        <th>{{ row.name }}</th>
        <td>{% if row.value %}<code>{{ row.value }}</code>{% else %}<span class="empty">None</span>{% endif %}</td>
      </tr>
      {% endfor %}
    </table>
  </fieldset>

  <fieldset class="section">
    <legend>Editable</legend>
    <table>
      {% for row in editable_rows %}
      <tr>
        <th><label for="{{ row.name }}">{{ row.name }}</label></th>
        <td>
          {{ row.input | safe }}
          {% if row.helper %}<div class="field-helper">{{ row.helper }}</div>{% endif %}
        </td>
      </tr>
      {% endfor %}
    </table>
  </fieldset>

  <div class="form-actions">
    <button type="submit" class="btn btn-primary">Save</button>
    <a href="/orgs" class="btn-link">Cancel</a>
  </div>
</form>

<form method="post" action="/orgs/{{ slug }}/edit/branding" enctype="multipart/form-data" class="edit-form" id="branding-form">
  <fieldset class="section">
    <legend>Branding assets</legend>
    <table>
      <tr>
        <th><label for="logo">Logo</label></th>
        <td>
          {% if logo_url %}<img class="brand-asset-preview" src="{{ logo_url }}" alt="">{% endif %}
          <input type="file" id="logo" name="logo" accept="image/png,image/jpeg,image/webp">
          <div class="field-helper">PNG, JPEG, or WebP. Max 1 MB. Leave empty to keep the current logo.</div>
        </td>
      </tr>
      <tr>
        <th><label for="favicon">Favicon</label></th>
        <td>
          {% if favicon_url %}<img class="brand-asset-preview" src="{{ favicon_url }}" alt="">{% endif %}
          <input type="file" id="favicon" name="favicon" accept="image/png,image/x-icon">
          <div class="field-helper">PNG or ICO. Max 1 MB. Leave empty to keep the current favicon.</div>
        </td>
      </tr>
    </table>
  </fieldset>
  <div class="form-actions">
    <button type="submit" class="btn btn-primary">Save branding assets</button>
  </div>
</form>

{# v0.27.8 (#78) — Open admin as superuser. Posts to
   /orgs/{slug}/impersonate which mints a short-lived tenant
   session cookie (1h default), pinned to this slug, and 302s
   to the tenant admin's /__admin/. Banner + audit entries make
   the session visible + traceable. Hidden if impersonation
   wasn't enabled at boot (the operator console route only
   mounts when the tenant session secret is shared). #}
{% if impersonate_enabled %}
<form method="post" action="/orgs/{{ slug }}/impersonate" class="edit-form">
  {{ csrf_input | default(value='') | safe }}
  <fieldset class="section">
    <legend>Impersonate (sign in as superuser)</legend>
    <p class="section-note">
      Open this tenant's admin as a synthetic operator-superuser.
      You'll see an unmissable banner during the session; every
      write is recorded as
      <code>operator:&lt;your-id&gt;:impersonating</code> in the
      tenant's audit log. Session expires after the configured
      TTL (default 1&nbsp;hour).
    </p>
  </fieldset>
  <div class="form-actions">
    <button type="submit" class="btn btn-primary">Open admin as superuser →</button>
  </div>
</form>
{% endif %}

{#- Two actions with very different consequences, kept visibly apart.
    Deactivating is one click and reversible; purging asks for the slug
    and destroys the storage. No `edit_enabled` guard: this whole page
    is only mounted on a console that can edit. -#}
<fieldset class="section danger">
  <legend>Decommission</legend>

  <form method="post" action="/orgs/{{ slug }}/deactivate" class="inline-form"
        data-confirm="Deactivate {{ slug }}? It stops serving immediately; nothing is destroyed.">
    {{ csrf_input | default(value='') | safe }}
    <button type="submit" class="btn btn-secondary">Deactivate</button>
  </form>
  <div class="field-helper">Stops serving on the next request. The data
  stays and the tenant can be switched back on from this page.</div>

  <hr class="danger-rule">

  <form method="post" action="/orgs/{{ slug }}/purge" class="edit-form">
    {{ csrf_input | default(value='') | safe }}
    <p class="danger-lead"><strong>Purge — permanent.</strong>
    Drops this tenant's schema or database and removes its registry row.
    There is no undo and no backup taken.</p>
    <table>
      <tr>
        <th><label for="confirm">type the slug</label></th>
        <td>
          <input type="text" id="confirm" name="confirm" autocomplete="off"
                 placeholder="{{ slug }}">
          <div class="field-helper">Type <code>{{ slug }}</code> exactly.</div>
        </td>
      </tr>
      <tr>
        <th><label for="purge_database">drop the database</label></th>
        <td>
          <input type="checkbox" id="purge_database" name="purge_database" value="on">
          <div class="field-helper">Required for a database-mode tenant,
          where purging means <code>DROP DATABASE</code>. Schema-mode
          tenants ignore this.</div>
        </td>
      </tr>
    </table>
    <p class="form-actions">
      <button type="submit" class="btn btn-danger"
              data-confirm="Permanently destroy {{ slug }} and all its data?">Purge {{ slug }}</button>
    </p>
  </form>
</fieldset>

<script nonce="{{ csp_nonce }}">
  // Drops the returned fragment in place, so an operator can check a
  // tenant without losing unsaved edits to a page reload. The URL is
  // resolved server-side and never sent here — it carries a password.
  document.getElementById("test-connection").addEventListener("click", async (e) => {
    const out = document.getElementById("probe-result");
    e.target.disabled = true;
    out.textContent = "checking…";
    try {
      const res = await fetch("/orgs/{{ slug }}/test-connection", {
        method: "POST",
        headers: { "X-CSRF-Token": csrfToken() },
      });
      out.innerHTML = await res.text();
    } catch (err) {
      out.textContent = "could not reach the console: " + err;
    } finally {
      e.target.disabled = false;
    }
  });

  // A multipart body is one `CsrfLayer` cannot read, so the upload sends
  // the token as a header and follows the handler's redirect (#1710).
  document.getElementById("branding-form").addEventListener("submit", async (e) => {
    e.preventDefault();
    const res = await fetch(e.target.action, {
      method: "POST",
      headers: { "X-CSRF-Token": csrfToken() },
      body: new FormData(e.target),
    });
    if (!res.redirected) {
      alert("Upload failed (" + res.status + "): " + (await res.text()));
      return;
    }
    window.location = res.url;
  });
</script>
{% endblock %}