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.

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: 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.
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:
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.

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[]:
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.