{% extends "op_layout.html" %}
{% block title %}New tenant — {{ brand_name | default(value='Rustango Operator Console') }}{% endblock %}
{% block content %}
<p class="breadcrumb"><a href="{{ console_prefix }}/orgs">Organizations</a><span class="sep">/</span>New tenant</p>
<h1>New tenant</h1>
<p class="lead">Reaches the database, registers the tenant, runs its
migrations, and only then makes it live. Nothing is written until the
connection check passes.</p>
{% if key_warning %}<div class="alert alert-error key-warning">{{ key_warning }}</div>{% endif %}
{% if error %}
<div class="alert alert-error alert-lines">{{ error }}</div>
{% endif %}
<form method="post" action="{{ console_prefix }}/orgs/new" class="edit-form">
{{ csrf_input | default(value='') | safe }}
<fieldset class="section">
<legend>Identity</legend>
<table>
<tr>
<th><label for="slug">slug</label></th>
<td>
{#- The hyphen is escaped on purpose. `[a-z0-9-]` is a syntax
error under the `v`-flag regex the HTML spec now uses for
`pattern`, and a browser that cannot parse the attribute
ignores it *silently* — so the field had no client-side
validation at all, which is how a slug with a space got
through to the server. -#}
<input type="text" id="slug" name="slug" required autofocus
value="{{ prefill.slug | default(value='') }}"
pattern="[a-z0-9]([a-z0-9\-]*[a-z0-9])?"
maxlength="63"
title="Lowercase letters, digits and hyphens; cannot start or end with a hyphen."
placeholder="acme">
<div class="field-helper">Globally unique, lowercase. Also the
default schema name, display name and subdomain label.</div>
</td>
</tr>
<tr>
<th><label for="display_name">display_name</label></th>
<td>
<input type="text" id="display_name" name="display_name"
value="{{ prefill.display_name | default(value='') }}"
placeholder="defaults to the slug">
</td>
</tr>
</table>
</fieldset>
<fieldset class="section">
<legend>Storage</legend>
<table>
<tr>
<th><label for="storage_mode">storage_mode</label></th>
<td>
<select id="storage_mode" name="storage_mode">
<option value="database" {% if prefill.storage_mode | default(value="database") != "schema" %}selected{% endif %}>database — its own database</option>
{% if schema_mode_available %}
<option value="schema" {% if prefill.storage_mode | default(value="") == "schema" %}selected{% endif %}>schema — a schema in the registry database</option>
{% endif %}
</select>
<div class="field-helper mode-database">Database: the tenant's data lives in a
database of its own. Choose below where it comes from.</div>
<div class="field-helper mode-schema" hidden>Schema: the data lives in a
schema in the registry database. It uses the registry's login
(<code>DATABASE_URL</code>); tenants share it and get no login of their own.
Needs: that login may create schemas there (<code>CREATE</code> on the database).</div>
{% if not schema_mode_available %}
<div class="field-helper">Schema mode needs a Postgres
registry — this one is not, so only database mode is offered.</div>
{% endif %}
</td>
</tr>
<tr>
<th>backend</th>
<td>
<code id="backend">{{ backend }}</code>
<div class="field-helper">A tenant uses the registry's backend.</div>
</td>
</tr>
{# ---- database mode: a new database on a saved server, or one that exists ---- #}
{% set source = prefill.database_source | default(value=default_source) %}
<tr class="mode-database">
<th>database</th>
<td>
<label><input type="radio" name="database_source" value="server"
{% if source == "server" %}checked{% endif %}
{% if not servers %}disabled{% endif %}>
Create a new database on a saved server</label>
<div class="field-helper">{% if backend == "sqlite" %}A new file in the
server's directory; the app must be able to write there.{% else %}Uses the
server's admin login once to create the database; the tenant gets its own
login. Needs: create databases, create logins, grant rights.{% endif %}</div>
<label><input type="radio" name="database_source" value="url"
{% if source == "url" %}checked{% endif %}>
Use an existing database URL</label>
<div class="field-helper">Nothing is created; the database must exist. The
tenant uses the login in the URL. No admin rights needed.</div>
<label><input type="radio" name="database_source" value="registry"
{% if source == "registry" %}checked{% endif %}>
A database on the registry's server (uses the registry's login — not isolated)</label>
<div class="field-helper">Not isolated: the tenant uses the registry's login
(<code>DATABASE_URL</code>) and shares it. Nothing is created; the database
must exist.</div>
{% if not servers %}
<div class="field-helper">No saved database server yet —
<a href="{{ console_prefix }}/db-servers">add one</a> to create
databases from here.</div>
{% endif %}
</td>
</tr>
<tr class="mode-database source-server">
<th><label for="server">server</label></th>
<td>
<select id="server" name="server">
{% for s in servers %}
<option value="{{ s }}" {% if prefill.server | default(value="") == s %}selected{% endif %}>{{ s }}</option>
{% endfor %}
</select>
<div class="field-helper">The database is named
<code class="slug-echo"><slug></code>, and gets a login of
its own with a random password, granted that database only.
It must not exist yet.</div>
</td>
</tr>
<tr class="mode-database source-registry">
<th><label for="database_name">database name</label></th>
<td>
<input type="text" id="database_name" name="database_name"
value="{{ prefill.database_name | default(value='') }}"
placeholder="tenant_acme">
<div class="field-helper">
A database on the same server as the registry. Blank uses
<code id="database-name-default">tenant_<slug></code>.
{% if derived_url_example %}
Becomes <code id="derived-url">{{ derived_url_example }}</code>.
{% endif %}
</div>
</td>
</tr>
<tr class="mode-database source-url">
<th><label for="database_url">database URL</label></th>
<td>
{#- A URL may hold a password: masked, and never sent back. -#}
<input type="password" id="database_url" name="database_url"
autocomplete="off"
placeholder="postgres://user:password@host/db">
<div class="field-helper">A database that exists, with its own
login. An <code>env://</code> reference works too.</div>
</td>
</tr>
{# ---- schema mode: nothing to name but the schema ---- #}
<tr class="mode-schema" hidden>
<th><label for="schema_name">schema</label></th>
<td>
{#- Same escaped hyphen as the slug, and for the same reason:
an unescaped one makes the attribute a syntax error the
browser drops silently. Underscores are allowed here and
not in the slug — a schema name is an identifier, not a
hostname label. -#}
<input type="text" id="schema_name" name="schema_name"
value="{{ prefill.schema_name | default(value='') }}"
pattern="[a-z0-9_][a-z0-9_\-]*"
maxlength="63"
title="Lowercase letters, digits, underscores and hyphens."
placeholder="defaults to the slug">
<div class="field-helper">The tenant lives in the registry's
own database, isolated by <code>search_path</code>. There is no
separate connection to configure.</div>
</td>
</tr>
<tr>
<th></th>
<td>
<button type="button" class="btn btn-secondary" id="test-connection">Test connection</button>
<div class="field-helper">Checks the rights the chosen option needs. Creates nothing.</div>
<div id="probe-result"></div>
</td>
</tr>
</table>
</fieldset>
<fieldset class="section">
<legend>Routing</legend>
<table>
<tr>
<th><label for="host_pattern">host_pattern</label></th>
<td>
{#- Uppercase is allowed through and lowercased by the server,
because that is what the resolver does to the `Host`
header before comparing. A `:port` or a `*` is not: they
can never equal the normalized header. -#}
<input type="text" id="host_pattern" name="host_pattern"
value="{{ prefill.host_pattern | default(value='') }}"
pattern="[A-Za-z0-9]([A-Za-z0-9\-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9\-]*[A-Za-z0-9])?)*"
maxlength="253"
title="A hostname, matched exactly. No port and no wildcard — put the port in the port field."
placeholder="acme.example.com">
<div class="field-helper">Matched exactly against the
<code>Host</code> header. Blank derives
<code><slug>.<RUSTANGO_APEX_DOMAIN></code> when that
variable is set.</div>
</td>
</tr>
<tr>
<th><label for="path_prefix">path_prefix</label></th>
<td>
<input type="text" id="path_prefix" name="path_prefix"
value="{{ prefill.path_prefix | default(value='') }}"
pattern="/[A-Za-z0-9_.~\-]+"
title="One path segment, with its leading slash."
placeholder="/acme">
<div class="field-helper">Only the first path segment is
matched, so this is <code>/name</code> — not a deeper path.</div>
</td>
</tr>
<tr>
<th><label for="port">port</label></th>
<td><input type="number" id="port" name="port" inputmode="numeric"
min="1" max="65535"
value="{{ prefill.port | default(value='') }}"></td>
</tr>
</table>
</fieldset>
<fieldset class="section">
<legend>Migrations</legend>
<table>
<tr>
<th><label for="no_migrate">skip</label></th>
<td>
<input type="checkbox" id="no_migrate" name="no_migrate" value="on"
{% if prefill.no_migrate | default(value=false) %}checked{% endif %}>
<div class="field-helper">Register the tenant without building
its schema. It still goes live; migrate it later.</div>
</td>
</tr>
</table>
</fieldset>
{% if first_admin %}
<fieldset class="section">
<legend>First admin</legend>
<table>
<tr>
<th><label for="admin_email">email</label></th>
<td>
<input type="email" id="admin_email" name="admin_email" maxlength="254"
value="{{ prefill.admin_email | default(value='') }}"
placeholder="optional">
<div class="field-helper">Creates a superuser who sets their own password
through a single-use link{% if first_admin == "mail" %}, mailed to this
address{% else %}, shown to you once when the tenant is ready{% endif %}.</div>
</td>
</tr>
<tr>
<th><label for="admin_username">username</label></th>
<td>
<input type="text" id="admin_username" name="admin_username" maxlength="64"
value="{{ prefill.admin_username | default(value='') }}"
placeholder="defaults to the email">
</td>
</tr>
</table>
</fieldset>
{% endif %}
<p class="form-actions">
<button type="submit" class="btn btn-primary">Create tenant</button>
<a href="{{ console_prefix }}/orgs" class="btn-link">Cancel</a>
</p>
</form>
<script nonce="{{ csp_nonce }}">
const mode = document.getElementById("storage_mode");
const serverPick = document.getElementById("server");
function source() {
const picked = document.querySelector('input[name="database_source"]:checked');
return picked ? picked.value : "url";
}
(function () {
const slug = document.getElementById("slug");
const dbName = document.getElementById("database_name");
const derived = document.getElementById("derived-url");
const nameDefault = document.getElementById("database-name-default");
const derivedTemplate = derived ? derived.textContent : null;
function applyMode() {
const schema = mode.value === "schema";
for (const row of document.querySelectorAll(".mode-database")) {
row.hidden = schema;
}
for (const row of document.querySelectorAll(".mode-schema")) {
row.hidden = !schema;
}
for (const kind of ["server", "url", "registry"]) {
for (const row of document.querySelectorAll(".source-" + kind)) {
row.hidden = schema || source() !== kind;
}
}
}
function applySlug() {
const s = slug.value.trim() || "<slug>";
for (const echo of document.querySelectorAll(".slug-echo")) {
echo.textContent = s;
}
const name = dbName.value.trim() || "tenant_" + s;
if (nameDefault) { nameDefault.textContent = "tenant_" + s; }
if (derived && derivedTemplate) {
derived.textContent = derivedTemplate.replace("tenant_<slug>", name);
}
}
mode.addEventListener("change", applyMode);
for (const radio of document.querySelectorAll('input[name="database_source"]')) {
radio.addEventListener("change", applyMode);
}
slug.addEventListener("input", applySlug);
dbName.addEventListener("input", applySlug);
applyMode();
applySlug();
})();
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(consoleUrl("/orgs/test-connection"), {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"X-CSRF-Token": csrfToken(),
},
body: new URLSearchParams({
storage_mode: mode.value,
database_source: source(),
server: serverPick ? serverPick.value : "",
database_url: document.getElementById("database_url").value,
database_name: document.getElementById("database_name").value,
slug: document.getElementById("slug").value,
}),
});
await showProbe(out, res);
} catch (err) {
out.textContent = "could not reach the console: " + err;
} finally {
e.target.disabled = false;
}
});
</script>
{% endblock %}