# VPN users and devices

> How VPN users relate to a network, how per-device credentials and one-time tokens work, what revoke, suspend and delete reach the engine, and how pinned addresses, bandwidth buckets, connection caps and TOTP enrolment behave.

The **Users & devices** screen provisions the identities that may join a
TeleQuick VPN network, and issues and revokes the per-device credentials those
identities actually connect with. Everything on the screen is backed by the
`adminVpn` tRPC router.

| Procedure | Purpose |
| --------- | ------- |
| `adminVpn.listUsers` | The user rows, their credentials and each credential's live connection count. |
| `adminVpn.listNetworks` | The networks a user may belong to, including each network's `require_mfa` flag. |
| `adminVpn.upsertUser` | Create or update a user row. |
| `adminVpn.deleteUser` | Remove a user. |
| `adminVpn.issueCredential` | Mint a per-device credential; returns the token once. |
| `adminVpn.configFor` | Render the runnable install command for a token. |
| `adminVpn.revokeCredential` | Kill one device's credential. |
| `adminVpn.beginMfaEnrollment` | Generate a TOTP secret and provisioning URI. |
| `adminVpn.confirmMfaEnrollment` | Verify a code and activate the factor. |
| `adminVpn.resetMfa` | Clear an enrolled factor. |
| `adminVpn.mfaReadiness` | Enrolment state used to badge users on networks that require MFA. |

## How users, networks and credentials fit together

There are three levels, and they nest strictly:

1. **A network** defines the tunnel address space and the endpoint that devices
   dial. It also carries the `require_mfa` policy flag.
2. **A user** is an identity scoped to exactly one network. `network_id` is
   `NOT NULL` — a user cannot exist without a network, because the config a
   device runs is rendered from the network's endpoint. With no network
   configured, this screen links you to **Networks** rather than offering a form
   that could only fail.
3. **A credential** is per device. You add devices after the user exists, and
   each device gets its own independently revocable credential.

The user row itself holds:

| Field | Notes |
| ----- | ----- |
| `name` | The username. Sent in the client's hello frame, so it is restricted to letters, digits, dot, underscore and hyphen, must start with a letter or digit, and is capped at 64 characters. |
| `email` | Optional. Stored as `null` when blank. |
| `status` | `active` or `suspended`. |
| `static_ip` | Optional pinned tunnel address. |
| `bandwidth_bps` | The engine's token-bucket rate, in bits per second. |
| `max_conns` | Concurrent-connection cap for this user. |
| `routes` | Extra CIDRs pushed to this user's devices. |

The list view polls `listUsers` every 10 seconds, which is also how the live
connection counts stay current.

## Create or edit a user

`upsertUser` takes the **whole** row, not a patch. The console always sends every
field, because a partial write would silently clear the fields it omitted —
suspending someone must not drop their pinned IP.

```ts
await trpc.adminVpn.upsertUser.mutate({
  id: user.id,              // null / omitted for a new user
  networkId: user.network_id,
  name: "alice",
  email: "alice@example.com", // or null
  status: "active",           // "active" | "suspended"
  staticIp: "10.88.0.10",     // bare IPv4, or null
  bandwidthBps: 5_000_000,
  maxConns: 2,
  routes: ["10.0.0.0/8"],
});
```

The form validates before it writes: the username against the character rule
above, the email as an email, `staticIp` as an IPv4 address, `bandwidthBps` as a
number ≥ 0, `maxConns` as an integer ≥ 0, and every entry in `routes` as a CIDR.
An invalid form does not disable the save button — clicking it reveals the
per-field messages instead, naming the first bad list entry.

## Per-device credentials and the one-time token

`issueCredential` returns a token, and **that is the only time the token is
shown**. Only its SHA-256 is stored, so there is no "show again" and no recovery
path. The console therefore reveals it in a modal that is explicit about this and
offers copy buttons for both the token and the install command.

If a token is lost, you do not recover it. You revoke that device and issue a new
credential.

Immediately after a successful issue, the console calls `configFor` with the same
token so the runnable command can be shown alongside it — it is now or never:

```ts
const { token } = await trpc.adminVpn.issueCredential.mutate(/* … */);
const { command } = await trpc.adminVpn.configFor.query({
  orgId,
  vpnUserId,
  token,
});
```

`command` is a single line to run on the device. It targets Linux and needs root,
because it creates the TUN interface. The command is rendered from the network's
endpoint, so if the network has no endpoint host set, the console shows
`(set an endpoint host on the network to generate this)` in its place — fix the
network, then issue again.

## Revoke, suspend and delete: what reaches the engine and when

Every mutation on this screen re-projects the credential set that the engine
authenticates against into Redis. That has one consequence worth internalising:

- **Revoke means now.** `revokeCredential` does not merely refuse the next
  reconnect. The engine drops the live session on its next refresh, roughly five
  seconds later. The live connection count next to each device is how you
  confirm it actually happened.
- **Suspend means now.** Setting `status: "suspended"` via `upsertUser` takes
  effect on the same refresh, for the same reason.
- **Delete means now.** `deleteUser` behaves identically.

Because the projection is the mechanism, a failed mutation means the engine was
**not** updated. See [When a mutation fails](#when-a-mutation-fails).

## Pinned addresses, bandwidth and connection caps

**Pinned address.** `static_ip` pins the user to one tunnel address. The column
is Postgres `inet`, so it reads back as a host route (`10.88.0.10/32`) while the
procedure input takes a bare IPv4 address — the console strips the prefix on read
and sends the bare form on write. The engine rejects a credential row whose IP
will not parse, so the field is validated as IPv4 before it is saved.

**Bandwidth.** The engine's bucket is measured in bits per second, but operators
think in Mbit/s, so the console converts on both edges: Mbit/s × 1,000,000 → `bandwidthBps`
on write, and `bandwidth_bps` ÷ 1,000,000 → Mbit/s (three decimal places) on
read. If you call `upsertUser` directly, send bits per second.

**Connection cap.** `maxConns` is an integer and bounds how many concurrent
connections the user may hold.

Both `bandwidthBps` and `maxConns` default to `0` in the new-user form and must be
≥ 0.

## Extra routes for a user

`routes` is a list of CIDRs attached to the individual user, on top of whatever
the network already pushes. In the console you type them comma- or
space-separated; the API takes a `string[]`:

```ts
routes: ["10.0.0.0/8", "192.168.50.0/24"]
```

Each entry is validated as a CIDR, and the error message names the offending
entry rather than failing the whole field anonymously. Remember that `routes` is
part of the full-row write — omitting it clears it.

## TOTP enrolment, re-enrolment and reset

Enrolment is deliberately two-phase.

1. `beginMfaEnrollment({ orgId, vpnUserId })` returns a `secret` and a
   provisioning `uri`. The console renders the URI as a QR code and also shows
   the secret grouped in fours for manual entry. The factor is time-based, six
   digits, 30-second step. If the QR fails to render, the printed key is the same
   secret and any authenticator accepts manual entry.
2. `confirmMfaEnrollment({ orgId, vpnUserId, code })` verifies a code produced by
   that authenticator.

Until step 2 succeeds the factor stays **inert** and is not projected to the
engine. A secret that has been generated but never successfully used is not
protection, and if it counted as enrolled the network policy would start refusing
a user whose authenticator holds nothing.

The console accepts a code of 6–12 digits (mainstream authenticators all emit 6).
After confirmation the user is asked for a code from that authenticator when
connecting.

**Re-enrolment and reset.** There are no recovery codes to hand out — on purpose.
If a user loses their authenticator, call `resetMfa` to clear the factor, then run
enrolment again against the new device. Both `confirmMfaEnrollment` and
`resetMfa` invalidate `listUsers` and `mfaReadiness`.

**Where it is enforced.** Enforcement is a property of the network, not the user:
the screen reads `require_mfa` from `listNetworks` and treats "not enrolled" as a
warning for users on a network that requires MFA, and as a neutral note
elsewhere. Change the requirement on the network itself.

## Tags and filtering

Users are taggable under the resource type `vpn_user`. The tag filter in the page
header narrows the table client-side and reports how many of the total rows
matched, so you can, for example, pull up every contractor identity across a
network before revoking their devices.

## When a mutation fails

`upsertUser`, `deleteUser`, `issueCredential`, `revokeCredential` and `resetMfa`
all change who may connect, and all of them therefore end in a re-projection.
The screen surfaces their errors in a banner rather than swallowing them, because
a failure means the engine still holds the previous credential set. Errors are
normalised for display — a Zod issue array is rendered as
`Email: Invalid email` rather than raw JSON.

If you see that banner after clicking **Revoke**, treat the device as still
connected and retry.
