> ## Documentation Index
> Fetch the complete documentation index at: https://omniflow-vpn.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Admin panel

> First-owner setup, operator roles, sessions, and the audit trail.

The admin panel is the operator console at `/admin`. Its API lives at `/v1/panel`
and is authenticated with a session cookie, not the `APP_OPERATOR_TOKEN` bearer
token used by `/v1/admin`. The two surfaces are deliberately separate: they have
different trust models, and sharing a prefix would let one inherit the other's
middleware.

## First-owner setup

A fresh installation has no operator account, so nothing can sign in yet. On
first start the API mints a one-time setup token and writes it to its log:

```
level=WARN msg="no administrator exists yet; redeem this one-time setup token at /admin/setup" token=... expires_in=30m0s
```

Open `/admin/setup`, paste the token, and create the first owner. The token is
valid for 30 minutes and can be redeemed once.

The bootstrap path closes permanently as soon as any operator account exists. A
token that leaked before that point cannot be redeemed afterwards, and requesting
a new token retires any outstanding one.

> [!WARNING]
>
> The setup token grants owner access to the whole installation. Read it from the
> server log directly rather than forwarding the log to a chat or ticket.

## Roles and permissions

Roles are built in. The permission catalogue and the role-to-permission mapping
are compiled into the application, so a database edit cannot widen access; the
database records only which roles an operator holds.

| Role | What it can do |
| - | - |
| `owner` | Everything, including creating operators and changing role grants |
| `administrator` | All day-to-day operations and settings; cannot manage operator accounts |
| `support` | Customers, subscriptions, and the support desk; reads risk, gifts, and the shop |
| `finance` | Orders, payments, refunds, wallet adjustments, and audit export; revokes and refunds unredeemed gifts |
| `marketing` | Campaigns, news, referral configuration, and personal offers |
| `auditor` | Read-only across every surface, plus audit export (not the customer export) |

Only an owner may create or invite an operator, change role grants, or grant
the owner role. Keeping that owner-only means a compromised administrator session cannot
mint itself a second account or widen its own privileges.

The installation always keeps at least one active owner. Revoking the last
owner's role, or suspending the last owner, is refused rather than leaving the
panel unadministrable.

Permissions are enforced in the Go API on every request. The panel renders from
the same permission set it receives, so hiding a control is presentation only —
reaching a hidden route grants nothing.

v0.7 adds `risk`, `gifts`, and `goods` to the catalogue. What they cover and why
they are separate capabilities is described in
[Admin operations](https://omniflow-vpn.mintlify.site/operations/admin-operations.md).

## Inviting operators and changing roles

An owner adds an operator from `/admin/operators` with **Invite operator**:
an email address and one or more roles. The API answers with a single-use link,
`/admin/invite/<token>`, and the panel shows it **once** with a copy button.
Omniflow has no email transport, so the owner sends the link themselves, the
same way they would hand over any other credential.

* The link works once and expires after **72 hours**. Only the SHA-256 of its
  token is stored; the panel cannot show it again.
* Inviting the same address again retires the earlier link, so a lost or
  intercepted link is replaced rather than left usable.
* An address that already has an operator account cannot be invited; change
  that operator's roles instead.
* **Pending invitations** lists every link not yet used, marks the expired
  ones, and offers **Revoke**.

The invitee opens the link outside the panel shell, sees the address and roles
they were invited with, and chooses a display name and a password (at least 12
characters, with the same strength meter as the security page). They are
signed in straight away and offered `/admin/security` to enrol two-factor or a
passkey; the installation does not force either.

**Change roles** on an operator's row opens the role editor. The API enforces
these rules on every change, and the editor greys out what it would refuse:

* Nobody can change their own roles.
* Every role added **or removed** must carry only permissions the person making
  the change already holds, and only an owner can grant or remove the owner
  role.
* An operator keeps at least one role; suspension is how access is taken away.
* The installation keeps at least one active owner. Role changes that could
  remove an owner are serialised, so two owners cannot demote each other at once.
* Saving an unchanged set is a no-op and is not recorded.

The **Roles and permissions** table on the same screen is read from the
compiled-in catalogue the API enforces, one row per area.

Invitation issue, revocation, and acceptance are audited as
`admin.invitation.created`, `admin.invitation.revoked`, and
`admin.invitation.accepted`; role changes as `admin.account.roles_changed` with
the before and after sets.

## Sessions

Only the SHA-256 digest of a session token is stored, so a database read or an
offline backup never yields a usable cookie.

Two expiries run at once:

* **Inactivity** — 30 minutes, slid forward on each authenticated request.
* **Absolute** — 12 hours, never extended.

A session kept warm by a background tab still ends at the absolute horizon.
Tokens also rotate every 15 minutes, which bounds how long a token captured from
a log or a proxy stays replayable.

Each operator can review their live sessions at `/admin/security`, end any one of
them, or sign out everywhere else. Changing a password ends every other session;
completing a password reset ends all of them.

The same screen registers and revokes **passkeys**. A passkey signs in on its own
— the authenticator proves possession and verifies the person, so the sign-in
screen goes straight in with no password and no code. Each key carries a name the
operator chooses, which is what makes revoking the right one possible months
later; the list also shows when each was last used. Keys never replace the
password, so removing the last one locks nobody out. The button is absent when
the installation has no `APP_PUBLIC_URL` to bind credentials to, or when the
browser cannot run the ceremony. See [Security](https://omniflow-vpn.mintlify.site/operations/security.md).

The new-password field carries a strength meter. It estimates entropy over the
alphabet the password actually uses and then subtracts for the patterns that
make that alphabet a lie — too few distinct characters, a run from the keyboard
or the alphabet, a single unbroken word — and names the one or two things that
would help most. It is deliberately not a character-class checklist: "one upper,
one digit, one symbol" rewards `Password1!`, which is among the first guesses
any real attack makes, and penalises a long passphrase that is genuinely hard.

The meter never refuses a password. The minimum length is the API's rule and is
enforced there; a meter that blocked would be a second place deciding what a
valid password is.

## Two-factor authentication

TOTP follows RFC 6238 with 6-digit codes on a 30-second step, and works with any
standard authenticator app. Secrets are sealed with `APP_DATA_ENCRYPTION_KEY`
before storage.

Enrolment is two steps. Adding the secret stores it unconfirmed; it satisfies no
login challenge until the first code is verified. An abandoned enrolment leaves
the account exactly as it was.

The enrolment screen shows a QR code beside the secret. It is drawn in the
browser from the `otpauth://` URI the API returns, never fetched as an image:
that URI carries the secret itself, so requesting it from an image endpoint —
including this application's own — would put a credential into a request line,
a proxy log, and a cache. The secret is also printed as text, because an
authenticator running on the same device cannot photograph the screen it is on.

Confirming enrolment issues ten single-use recovery codes, shown once. Only their
digests are stored. Regenerating the set invalidates every unused code.

Turning two-factor off requires re-entering the password, because removing a
second factor is exactly what an attacker on a hijacked session would attempt.

## Sign-in protection

* Sign-in answers identically for a wrong password, an unknown address, and a
  disabled account, and an unknown address is verified against a decoy hash so it
  costs the same. Operator accounts therefore cannot be enumerated.
* Five consecutive failures lock an account, with the wait doubling on each
  further failure up to one hour.
* Requests are rate limited per address and per source address, so neither a
  single account nor a spray across many can be ground down.
* Password reset always answers `202`, whether or not the address matched an
  account.

> [!NOTE]
>
> There is no operator email transport, so the public reset request **issues no
> token**: it records the request and posts a notice to the **🔐 Admin
> security** topic. A token is issued only on the server, by the API binary's
> `admin reset-password` command, which prints it to that terminal:
>
> ```bash theme={null}
> docker compose exec api /app admin reset-password owner@example.com
> ```
>
> See [Security](https://omniflow-vpn.mintlify.site/operations/security.md#changing-and-resetting-a-password).

## Security notifications

Security-relevant events are posted to the **🔐 Admin security** topic in the
operator Telegram group, alongside the purchase and incident streams:

* a sign-in from an address the account has not used before
* a password change or a completed password reset
* two-factor being turned off
* the owner role being granted
* any other change to an operator's roles
* an invitation being accepted, which means a new person can now sign in

A notice names the event and the operator account and nothing else. An address,
a session token, and a client IP all stay out of the group chat; that detail
lives in the audit trail, behind the permissions that govern it.

Notices are written in the same transaction as the change they describe, so one
can never announce something that was rolled back, and they are deduplicated, so
a retried transaction produces a single message.

## Command palette and record search

`Ctrl+K` (`⌘K` on a Mac), the search field in the header, or the search icon on
a phone opens the command palette. It jumps to any screen the operator may
open, starts a common action (inviting an operator, setting up two-factor), and
finds records.

Record search asks `GET /v1/panel/search?q=` and shows up to five results per
group, each with a preview line and what it matched on:

| Group | Found by | Requires |
| - | - | - |
| Customers | Omniflow customer ID or its first 6+ characters, numeric Telegram ID, exact email, exact Remnawave username | `customers.read` |
| Orders | Order ID or its first 6+ characters, exact payment-provider reference | `finance.read` |
| Support tickets | Ticket ID or its first 6+ characters, a fragment of the subject (3+ characters) | `support.read` |
| Gifts | The full gift code | `gifts.read` |
| Access codes | The full wholesale access code | `catalog.read` |

A group the operator's roles cannot read is not queried and does not appear in
the response, so the palette never shows a record its link would refuse to
open. Emails are matched exactly, through the keyed fingerprint stored beside
the encrypted address, and the address is never returned. Omniflow does not
store Telegram @usernames, so a query that starts with `@` finds no customer
and the palette says so; use the numeric Telegram ID instead.

Every lookup is an index probe — an ID prefix is a primary-key range scan, and
ticket subjects use a trigram index from the `pg_trgm` extension, which the
migration enables (it is a trusted extension, so the database owner can create
it without superuser). Search is limited to 60 requests a minute per operator
and, like every other read in the panel, is not written to the audit trail.

## Audit trail

Every authentication, authorization, and configuration action is appended to
`audit_events`, which exposes reads only. Each row carries the actor, target,
action, category, outcome, reason, request ID, timestamp, and safe before/after
metadata.

Events are classified on two axes the panel filters and exports on:

* **Category** — `authentication`, `authorization`, `configuration`, `customer`,
  `financial`, `support`, `marketing`, `system`
* **Outcome** — `success`, `failure`, `denied`

A permission denial is recorded as well as refused: an operator repeatedly
reaching for something they cannot do is exactly what an audit review looks for.

Browse the trail at `/admin/audit`. Filters are held in the URL, so a view can be
bookmarked and shared. Anyone with `audit.export` can download the filtered trail
as CSV.

## OIDC sign-in

OIDC is configuration, not code: supply a discovery document and client
credentials under **Settings → OIDC**, and nothing provider-specific exists in
the application. Anything that speaks OpenID Connect Discovery works, and an
installation with no configured provider authenticates with passwords alone.

The flow is authorization code with PKCE. PKCE is used even though this is a
confidential client, because it binds the code to the originating request, so a
code intercepted at the redirect cannot be redeemed by anyone else. The
anti-forgery state and the PKCE verifier travel in a short-lived encrypted
cookie rather than the database, so an abandoned sign-in leaves nothing behind.

Every ID token is verified against the provider's published JWKS, issuer, and
audience before it is trusted. A linked identity records the issuer it was
linked at and matches only a token from that same issuer, so editing a
provider's issuer cannot hand its linked operators to somebody else. Changing
an operator provider is owner-only (`settings.write` plus `admins.roles`), and
a provider with linked operators cannot be repointed at another issuer at all —
see [OIDC sign-in](https://omniflow-vpn.mintlify.site/integrations/oidc.md#issuer-pinning).

Two policies govern which account a subject reaches:

* **Verified email required** (default on). A provider may assert an address it
  has not verified; requiring the claim stops it from minting access to an
  account it does not control.
* **Auto-provisioning** (default off). A subject that matches no linked account
  is refused. With auto-provisioning on, a subject whose address is *not* already
  in use gets a fresh account in the configured role. An address that already
  belongs to an operator is never silently adopted — linking has to be a
  deliberate act by someone already signed in. The owner role can never be
  auto-provisioned.

Redirect URI to register with the provider:

```
https://<your-host>/v1/panel/auth/oidc/<slug>/callback
```

## Configuration

| Variable | Default | Purpose |
| - | - | - |
| `APP_ADMIN_PANEL_ENABLED` | `true` | Mounts `/v1/panel`. Turn off for a bot-only installation. |
| `APP_ADMIN_COOKIE_SECURE` | `true` | Marks the session cookie `Secure`. |
| `APP_ADMIN_TOTP_ISSUER` | `Omniflow` | Label shown in authenticator apps. |
| `APP_TRUSTED_PROXIES` | *(empty)* | Comma-separated IPs or CIDR blocks whose `X-Forwarded-For` may be believed. |
| `APP_DATA_ENCRYPTION_KEY` | *(required)* | Base64 32 bytes. Seals TOTP secrets. |

### Running behind a reverse proxy

`X-Forwarded-For` is trivially forged, so it is honoured only when the immediate
peer is listed in `APP_TRUSTED_PROXIES`. Leave the variable empty when the API is
exposed directly — the socket address is then used, which is correct.

The address feeds rate limiting and the audit trail, so getting this wrong in
either direction matters: trust nothing and every request behind a proxy shares
one address; trust too much and an attacker chooses their own.

> [!WARNING]
>
> `APP_ADMIN_COOKIE_SECURE=false` is for local development over plain HTTP only.
> Without `Secure`, the session cookie can be captured from any unencrypted
> request to the same host.

## Security headers

Every panel response carries `nosniff`, `X-Frame-Options: DENY`, a strict
`Content-Security-Policy` with `frame-ancestors 'none'`, `Referrer-Policy:
no-referrer`, and `Cache-Control: no-store`.

The session cookie is named `__Host-omniflow_admin` when it carries `Secure`,
which binds it to this exact origin: a browser refuses such a cookie with a
`Domain` attribute, so a sibling subdomain cannot set or overwrite the
operator's session.

That refusal is unconditional, and it includes the cookie's own arrival. A
browser rejects a `__Host-` cookie sent without `Secure` outright rather than
merely treating it as weaker, so with `APP_ADMIN_COOKIE_SECURE=false` the name
would make sign-in impossible instead of insecure — the cookie would be
discarded on arrival and every attempt would come back to the sign-in screen. The
prefix therefore follows the attribute it depends on: the cookie is
`__Host-omniflow_admin` when it is `Secure` and `omniflow_admin` when it is not.
The production name is the same as it has always been; only the plain-HTTP local
stack differs, and there the prefix would have been a claim the cookie could not
support anyway.

Every unsafe method additionally requires a per-session CSRF token echoed in
`X-CSRF-Token`; it is derived from a secret held with the session, so a token
minted for one session cannot be replayed in another.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.