Compare commits

...
8 Commits
Author SHA1 Message Date
mrhid6 18495dba68 feat: Restyle the API keys page onto the shared list patterns
Chart Release / chart (push) Successful in 25s
Server Deploy / deploy (push) Successful in 7m54s
The page was hand-rolling its loading spinner, error line and empty
paragraph while the rest of the console routes these through
AsyncBoundary with a TableSkeleton and an EmptyState — the same drift
Async.tsx was written to end. It also passed className="p-0" where Card
takes padding={false}.

The admin-only scope switch becomes the vulnerabilities page's pill
filter rather than a loose checkbox: seeing everyone's keys is a filter
over the list, not a preference, and someone who has learned one control
has now learned both.

Scopes collapse to one chip per resource with an r/rw qualifier. Sixteen
badges made the row taller than everything around it and still had to be
read one at a time.

In the create dialog Copy is now the primary action and Done the quiet
one, because the value is unrecoverable once the dialog closes, and the
result panel repeats the role, scopes and expiry that were just granted.
2026-08-13 08:59:06 +00:00
mrhid6 689d0e1d5b feat: Give API keys their own page and group the sidebar
The token management card sat on /settings, which is owner|admin
throughout, so it hid a capability every member already had: the API has
never required a role to mint or revoke your own key. It is now the
/api-keys page, reachable at every role, with the instance-wide lifetime
cap left behind on /settings because that is policy rather than one
person's credentials — and that split is what lets the page be ungated.

The sidebar gains groups: Fleet, Access, Automation, Instance, each with a
small-caps heading and a rule above it. Grouping is by what the operator
is doing rather than by which service answers, so SSH keys, secrets and
API keys sit together as credentials. A group whose every item is
admin-only disappears whole for a member; a labelled section with nothing
under it reads as a failure rather than a restriction.

The UI says keys while the collection, prefix and routes still say tokens.
Renaming a published endpoint to match a nav label would break every
script already written against it.
2026-08-13 08:54:42 +00:00
mrhid6 95527b3956 fix: Exclude /install and /update scripts from the OpenAPI document
handleInstallScript and handleUpdateScript are registered on the bare
gin engine at /install and /update, outside the /api group the
generated document's BasePath assumes. Their @Router annotations
therefore published /api/install and /api/update, paths that 404 —
the reference page told a reader to curl a URL that does not exist.

Removed the swag annotations from both handlers (replaced with a plain
comment explaining why) rather than adding a corrected @Router, since
swag has no per-route BasePath override and there is nothing lost by
leaving two shell-script endpoints out of a JSON API reference — their
.ps1 counterparts were already undocumented for the same reason.
Regenerated internal/api/docs/openapi.json accordingly.
2026-08-13 08:31:20 +00:00
mrhid6 225b53bfa7 fix: Throttle audit logging for expired API token use
Every request presenting an expired token wrote a token.expired_use
audit row, and RateLimitTokens only applies once a session exists, so a
rejected token was never rate-limited. A looping job with one expired
token could write an unbounded number of audit rows, drowning the real
audit trail.

services.ShouldLogExpiredTokenUse now dedupes to at most one
token.expired_use record per token per minute, mirroring the throttle
TouchAPIToken already uses for last-used. It lives in services rather
than auth because the storage concern belongs beside the token's other
storage-backed state. The first use per window is still recorded, which
is what makes a forgotten job visible.
2026-08-13 08:31:12 +00:00
mrhid6 965419b2b8 fix: Confine created API token scopes to the calling token's own
CreateAPIToken capped a new token's role at the creator's role but never
capped its scopes against the calling credential's scopes, and POST
/api/tokens required only settings:write. A token holding settings:write
alone could therefore mint a token holding keys:write or secrets:write,
reaching every SSH private key and vault secret in the instance.

createToken now refuses (403 scope_confinement) when the calling
credential is itself a token and any requested scope is not satisfied by
that token's own scopes, via services.ScopeSatisfied so servers:write
still permits granting servers:read. Cookie sessions are unaffected,
since their authority is the user's role. Also correct the createToken
doc comment, which claimed the scope cap already existed.

Also document why Hint stores 5 hex characters of the token secret.
2026-08-13 08:31:07 +00:00
mrhid6 f6988b0f1e docs: Correct API token access-control claim in CLAUDE.md 2026-08-13 08:12:48 +00:00
mrhid6 0784ef3719 docs: Document API tokens and the OpenAPI reference 2026-08-13 08:04:09 +00:00
mrhid6 9df4a29210 fix: Separate stacked securityDefinitions into distinct comment groups
swag v2.0.0-rc5's parseSecAttributesV3 resolves a security scheme's map key
via getSecurityDefinitionKey(lines), which scans from the start of whatever
comment-line slice it was handed and returns the first @securitydefinitions
match — ignoring the current parse position entirely. Three
@securityDefinitions.apikey blocks stacked in one Go comment group (the
three were separated only by bare '//' lines, which do not split an
ast.CommentGroup) therefore all resolved to the first block's name
(cookieAuth), with the last block's in/name/description winning: the
generated document had exactly one securityScheme, keyed cookieAuth, body
esoAuth.

Separating the three blocks with real blank source lines splits them into
three distinct ast.CommentGroups, so swag's file-level comment scan (which
requires no other tokens between them, same rule Go uses for doc comments)
hands each block its own line slice and each resolves its own key.
Regenerated openapi.json now carries all three schemes with correct
bodies, referenced with no dangling security requirements.
2026-08-13 07:54:56 +00:00
13 changed files with 647 additions and 263 deletions
+69 -1
View File
@@ -434,6 +434,60 @@ same commit.
`UpdateAgentCmd` carries a target version and Gitea base URL; the agent downloads and replaces itself.
### API tokens and OpenAPI
A token is `vt_` plus 32 random bytes hex, shown once at creation and stored
only as sha256 — the same shape as `servers.agent_token_hash` and the ESO read
token, and for the same reason: nothing downstream ever needs the plaintext
back. It belongs to the user who created it, and its role can never exceed
theirs; see the `api_tokens` note under MongoDB Collections for how that stays
true across a demotion rather than only at issuance. Scopes are eight
resources — `servers`, `keys`, `secrets`, `workflows`, `monitors`, `vulns`,
`workloads`, `settings` — each split into `:read` and `:write`, with `:write`
satisfying a `:read` requirement on the same resource so a caller does not have
to hold both. Any signed-in member may mint and revoke their **own** tokens —
there is no `RequireRole` on `POST /tokens` or `DELETE /tokens/:id` — because
`roleRank` already bounds what a token can do to no more than its creator's
own role, so a member cannot use a token to reach past themselves. Owner and
admin additionally see and revoke every token in the instance — `all=true` on
`GET /tokens` is gated by `elevated()` in `api/tokens.go`, and
`RevokeAPIToken` in `services/tokens.go` checks the same owner/admin condition
before letting a revoke target somebody else's token — neither is a
`RequireRole` middleware.
The `settings:read`/`settings:write` entries in `routeScopes` govern a
**token-authenticated** caller reaching the token endpoints — `RequireScopes`
no-ops entirely for a cookie session — so they say nothing about which human
role may call these routes with a session; that is `roleRank` and `elevated()`,
not the scope map. Expiry is optional per token; `settings.api_token_max_days` caps how
far out a new one may be set, and when that cap is set a token requested with
no expiry is refused rather than silently capped — the policy governs
issuance only and never reaches back to invalidate a token already issued.
`RateLimitTokens` holds every token to 600 requests/minute in a Redis fixed
window, answering 429 with `Retry-After`; cookie sessions are untouched; it
exists so a runaway script cannot take an instance down, not as the general
API rate-limiting project some future ticket might build.
**The UI calls them API keys and lives at `/api-keys`, not on `/settings`.**
The page is reachable at **every** role, which is the whole reason it is a page:
`/settings` is owner|admin throughout, so a card there hid a capability every
member has. `settings.api_token_max_days` stays on `/settings` because it is
instance policy rather than one person's credentials, and that split is exactly
what lets the page be ungated. The label differs from the identifiers on
purpose — the collection is `api_tokens`, the prefix is `vt_`, the routes are
`/api/tokens`, and renaming a published endpoint to match a nav label would
break every script already written against it.
`server/internal/api/docs/openapi.json` is a **generated, committed** OpenAPI
3.1 document — `swag v2` reading `@…` annotations off the handlers — served at
`GET /api/openapi.json` and rendered as a reference page by a vendored Scalar
bundle at `GET /api/docs`. `server-deploy.yml` regenerates it on every server
build and runs `git diff --exit-code` against the committed copy: a handler
whose annotation drifted from its code fails CI rather than shipping a
reference that lies. Scalar is vendored (`scalar.standalone.js`, served from
`GET /api/docs/scalar.js`) rather than pulled from a CDN, because the
reference page has to work on an air-gapped install with no outbound access at
all — the same requirement licence verification already meets.
### Marketing site and sitesvc
`site/` is a separate Next.js app built exactly like `web/``output: "standalone"`, run by Node in a `node:26-alpine` image, listening on `3000` and published as `3003`. The contact form posts to `sitesvc`; account signup posts to `admin` (`NEXT_PUBLIC_ADMIN_API_URL`), which creates an HQ account, not an org — the control plane is not touched until the customer later creates a cloud instance from the portal.
@@ -675,6 +729,8 @@ licence GET /license · POST /license (POST: self-hosted onl
org GET,POST /org/users · PUT /org/users/:id/role · DELETE /org/users/:id
providers GET,POST /auth/providers · PUT,DELETE /auth/providers/:id
POST /auth/providers/:id/{test,ack-notice} · GET /auth/presets (owner|admin)
tokens GET /tokens · GET /tokens/scopes · POST /tokens · DELETE /tokens/:id
GET /openapi.json · GET /docs
```
`GET /license` reports `deployment`, and **`POST /license` answers 409 `cloud_managed` when it is `cloud`**. A cloud instance's licence is written by `admin/internal/inject` straight into the database and never through this endpoint, so the refusal cannot break injection — it only stops a customer pasting over a licence they do not own. `web/` hides the paste form and points at the HQ portal instead, but as with `hq`-managed users, the API is the boundary and the UI is the courtesy.
@@ -749,7 +805,7 @@ Paddle is merchant of record; `admin/internal/paddle` is a thin REST client (no
## MongoDB Collections
`servers` · `keys` · `assignments` · `orgs` · `users` · `auth_providers` · `settings` · `secrets` · `workflows` · `workflow_steps` · `workflow_runs` · `workflow_log_lines` · `workflow_log_seq` · `monitors` · `incidents` · `monitor_rollups` · `notification_channels` · `console_sessions` · `audit_logs` · `server_packages` · `vuln_findings` · `vuln_alert_rules` · `vulndb_meta` · `server_workloads` · `migrations`
`servers` · `keys` · `assignments` · `orgs` · `users` · `auth_providers` · `settings` · `secrets` · `workflows` · `workflow_steps` · `workflow_runs` · `workflow_log_lines` · `workflow_log_seq` · `monitors` · `incidents` · `monitor_rollups` · `notification_channels` · `console_sessions` · `audit_logs` · `server_packages` · `vuln_findings` · `vuln_alert_rules` · `vulndb_meta` · `server_workloads` · `api_tokens` · `migrations`
Every document except `migrations` carries `org_id`. Struct definitions are the source of truth — see `server/internal/models/`.
@@ -768,6 +824,7 @@ Notes that are not obvious from the structs:
- `vuln_findings` is unique on `(instance_id, server_id, cve_id, package_name)`. That key is what makes a rescan an idempotent upsert rather than a duplicate factory, and what lets `first_seen` survive one. An empty `fixed_in` means no vendor fix exists — a real state, never "not vulnerable".
- `vulndb_meta` is a singleton and deliberately carries **no** `instance_id`: the vulnerability database is a property of the deployment, not a tenant. Same reasoning as `migrations`, and the reason it is absent from `services.ScopedCollections`.
- **`services.ScopedCollections` is the canonical registry of tenant-scoped collections**, and `scopedCollectionsForPurge` derives instance deletion from it rather than keeping a second list. A new collection carrying `instance_id` must be added there or its rows outlive the instance.
- `api_tokens` stores only `sha256` of the token, like `servers.agent_token_hash`. A token's effective role is `min(user.role, token.role)` **recomputed per request**, so demoting somebody demotes their tokens; deleting the user deletes them. Scopes are enforced from a map keyed on the registered gin route pattern, and `AssertScopeMapComplete` **fails boot** when an `/api` route is missing from it — a route added without an entry would otherwise be silently unreachable by every token.
Admin's own database is separate and holds `accounts` · `admin_instances` · `licenses` · `subscriptions` · `plans` · `catalogue` · `entitlements` · `paddle_events` · `staff_users` · `customer_users` · `instance_members` · `admin_audit`. `paddle_events` is the webhook idempotency log, unique on `event_id`: an event is claimed there before processing, and a duplicate of a handled event is a 200 no-op. `instance_members` is unique on `(instance_id, customer_user_id)` — one person holds at most one user in one instance, which makes a grant idempotent-by-refusal rather than silently doubling a projection. It is an _index_ of the control-plane rows, not the authority (see "Grants project, they do not federate"). Admin has no migrations collection; `models.Backfill` runs on every boot and is idempotent by filtering on the absence of what it writes.
@@ -965,9 +1022,20 @@ Customer nav is three destinations — Overview, People, Billing. Settings is in
| `/steps` | Reusable step library |
| `/monitors`, `/monitors/new`, `/monitors/[id][/edit]` | Checks, uptime, incidents |
| `/secrets`, `/secrets/[group]` | Vault |
| `/api-keys` | Personal API keys — reachable at **every** role, unlike `/settings` |
| `/audit` | Audit log |
| `/settings`, `/settings/notifications`, `/settings/license` | Members, OIDC, alerts, retention, ESO token · channels · licence |
**The sidebar is grouped, and the groups are the nav's structure rather than
decoration.** `web/components/Sidebar.tsx` holds `navGroups` — Fleet, Access,
Automation, Instance — each rendered with a mono small-caps heading and a
hairline rule above it, the first group excepted. Grouping is by what the
operator is doing, not by which service answers: SSH keys, vault secrets and
API keys sit together under Access because all three are credentials. A group
whose every item is `adminOnly` disappears **whole**, heading and rule
included, for a member — a labelled section with nothing under it reads as
something that failed to load rather than something withheld.
**`/settings` is one page, not a section.** Members and single sign-on used to
live at `/settings/instance` with their own sidebar entry; they are now the
**Access** group at the top of `/settings`, above **Monitoring** and
+110
View File
@@ -0,0 +1,110 @@
---
id: api-tokens
title: API tokens
sidebar_label: API tokens
---
A session cookie is fine for a browser. A script, a CI job or a cron task
needs something it can hold onto instead — an API token.
## Creating one
**API Keys**, in the Access group of the sidebar. The page is reachable at
every role: any member may create and revoke their own keys, and owner and
admin additionally see every key in the instance. Give it a name, a role
(owner, admin or member) and one or more scopes, and optionally an expiry. The value is shown
once, in full, immediately after creation:
```
vt_8f2c1a9e4b6d0735a1c8e29f4b0d6e17...
```
That is the only time you will see it. Vantage stores a hash of the token,
never the value itself, so if you lose it there is no support ticket that gets
it back — create a new token and revoke the old one.
## Scopes
A token can reach only what its scopes name. There are eight resources, each
with a `:read` and a `:write` scope, and holding `:write` on a resource also
satisfies a `:read` requirement for it — you do not need to tick both.
| Resource | Covers |
| ----------- | --------------------------------------------------- |
| `servers` | Fleet list, server detail, agent commands, tags |
| `keys` | SSH key library and assignment |
| `secrets` | The vault |
| `workflows` | Steps, workflows, runs and their logs |
| `monitors` | Monitors, incidents, uptime and notification channels |
| `vulns` | Vulnerability findings, packages and scan rules |
| `workloads` | Containers and systemd units, including control actions and logs |
| `settings` | Instance settings, members, single sign-on, licence, and token management itself |
A token created with only `servers:read` can list and inspect servers but
cannot run a workflow against them, touch a key, or read a secret — each of
those needs its own scope.
## A token never outranks its owner
A token's role can be at most the role of the person who created it, and its
effective role is **recomputed on every request** as the lower of the two —
not fixed at creation. Demote the person from owner to member and every token
they hold drops to member from that request onward. Remove the person and
every token they hold stops working immediately: a token has no existence
independent of its owner.
## Expiry
An expiry is optional on a token you create. An instance can set a
**maximum key lifetime** (Settings → Integrations) that caps how far out a new
token's expiry may be set; when that cap is in place, a token with no expiry
at all is refused, so there is no way to route around the policy by leaving
the field blank.
Changing the maximum lifetime only affects tokens created afterwards. It does
not shorten, extend or invalidate a token that already exists.
## Using a token
Send it as a bearer token:
```bash
curl -H "Authorization: Bearer vt_…" https://acme.vantage.example.com/api/servers
```
Everything else about the [REST API](./rest-api.md) applies the same way it
does to a session — JSON errors, audit logging, licence gating on writes —
except that authority comes from the token's role and scopes rather than a
signed-in person's role.
## Rate limit
A token is limited to **600 requests per minute**. Going over it gets a `429`
with a `Retry-After` header naming how many seconds to wait. Cookie sessions
are not subject to this limit; it exists so a runaway script cannot take an
instance down, not as a general throttle.
## Rotating a token
1. Create the replacement token first, with the scopes and role you need.
2. Deploy it wherever the old one was used, and confirm it works.
3. Revoke the old one.
Doing it in that order means there is no gap where the credential in use has
already been deleted.
## The full reference
This page covers the token model. Every route, request and response shape is
in the generated OpenAPI reference, served by **your own instance** at
`/api/docs` — not this documentation site, since the routes and their shapes
are specific to your install. The raw document is at `/api/openapi.json`.
:::danger Not the External Secrets token
The bearer token read by `GET /api/secrets/:group/values` for the Kubernetes
External Secrets Operator is a **separate credential** — a single instance-wide
value, rotated from Settings, that reaches only that one endpoint. It is not an
API token and an API token cannot be used in its place: the two are checked by
different code, and neither substitutes for the other. See
[Secrets](../vantage/secrets.md#kubernetes-external-secrets-operator).
:::
+1 -1
View File
@@ -43,7 +43,7 @@ const sidebars: SidebarsConfig = {
{
type: "category",
label: "Reference",
items: ["reference/environment-variables", "reference/rest-api", "reference/agent-config", "reference/ports-and-networking", "reference/troubleshooting"],
items: ["reference/environment-variables", "reference/rest-api", "reference/api-tokens", "reference/agent-config", "reference/ports-and-networking", "reference/troubleshooting"],
},
{
type: "category",
+23 -15
View File
@@ -29,24 +29,32 @@ import (
"github.com/gin-gonic/gin"
)
// @title Vantage API
// @version 1.0
// @description The Vantage control plane REST API. Authenticate with a browser session cookie, or with an API token created under Settings → API tokens.
// @BasePath /api
//
// @title Vantage API
// @version 1.0
// @description The Vantage control plane REST API. Authenticate with a browser session cookie, or with an API token created under Settings → API tokens.
// @BasePath /api
// Each @securityDefinitions.apikey block below is deliberately its own
// comment group, separated by a real blank line rather than a bare "//": Go's
// parser only splits ast.CommentGroups on an actual blank line, and
// swag v2.0.0-rc5's parseSecAttributesV3 resolves a scheme's map key by
// scanning from the start of whatever comment group it was handed — so three
// stacked blocks sharing one group all collapse onto the first block's name.
// Three groups means three independent scans, each finding its own name.
// @securityDefinitions.apikey cookieAuth
// @in cookie
// @name km_session
//
// @in cookie
// @name km_session
// @securityDefinitions.apikey bearerAuth
// @in header
// @name Authorization
// @description An API token, sent as "Bearer vt_…". Scoped and optionally expiring.
//
// @in header
// @name Authorization
// @description An API token, sent as "Bearer vt_…". Scoped and optionally expiring.
// @securityDefinitions.apikey esoAuth
// @in header
// @name Authorization
// @description The External Secrets read token, rotated under Settings. It reaches /api/secrets/{group}/values and nothing else. It is a different credential from an API token, and the two must never be substituted for one another.
// @in header
// @name Authorization
// @description The External Secrets read token, rotated under Settings. It reaches /api/secrets/{group}/values and nothing else. It is a different credential from an API token, and the two must never be substituted for one another.
func main() {
mongoURI := getEnv("MONGO_URI", "mongodb://localhost:27017")
+12 -63
View File
@@ -2002,7 +2002,18 @@
}
},
"securitySchemes": {
"bearerAuth": {
"description": "An API token, sent as \"Bearer vt_…\". Scoped and optionally expiring.",
"in": "header",
"name": "Authorization",
"type": "apiKey"
},
"cookieAuth": {
"in": "cookie",
"name": "km_session",
"type": "apiKey"
},
"esoAuth": {
"description": "The External Secrets read token, rotated under Settings. It reaches /api/secrets/{group}/values and nothing else. It is a different credential from an API token, and the two must never be substituted for one another.",
"in": "header",
"name": "Authorization",
@@ -3118,47 +3129,6 @@
]
}
},
"/install": {
"get": {
"description": "Dynamically generated shell script that downloads, verifies and installs the agent, seeded with a pre-registration token.",
"parameters": [
{
"description": "Server ID",
"in": "query",
"name": "server_id",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "Pre-registration token",
"in": "query",
"name": "token",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"text/plain": {
"schema": {
"type": "string"
}
}
},
"description": "shell script"
}
},
"summary": "Agent install script (Linux)",
"tags": [
"install"
]
}
},
"/instance/users": {
"get": {
"responses": {
@@ -7129,7 +7099,7 @@
]
},
"post": {
"description": "The plaintext token is returned exactly once and stored nowhere. A token's role and scopes cannot exceed the creator's own.",
"description": "The plaintext token is returned exactly once and stored nowhere. A token's role cannot exceed the creator's own; when the request is itself token-authenticated, its scopes cannot exceed the calling token's scopes either.",
"requestBody": {
"content": {
"application/json": {
@@ -7324,27 +7294,6 @@
]
}
},
"/update": {
"get": {
"description": "Dynamically generated shell script that downloads and installs the latest agent.",
"responses": {
"200": {
"content": {
"text/plain": {
"schema": {
"type": "string"
}
}
},
"description": "shell script"
}
},
"summary": "Agent update script (Linux)",
"tags": [
"install"
]
}
},
"/vuln-rules": {
"get": {
"responses": {
+13 -18
View File
@@ -711,14 +711,13 @@ func applyUpdates(c *gin.Context) {
c.JSON(http.StatusAccepted, MessageResponse{Message: "apply updates command sent to agent"})
}
// handleUpdateScript godoc
//
// @Summary Agent update script (Linux)
// @Description Dynamically generated shell script that downloads and installs the latest agent.
// @Tags install
// @Produce plain
// @Success 200 {string} string "shell script"
// @Router /update [get]
// handleUpdateScript serves a dynamically generated shell script that
// downloads and installs the latest agent. Deliberately not in the generated
// OpenAPI document: it is registered on the bare engine, not under the /api
// group the document's BasePath assumes, so a @Router annotation here would
// publish /api/update — a path that 404s — rather than the real top-level
// /update. It serves a shell script, not JSON, so there is nothing lost by
// leaving it out of a JSON API reference.
func handleUpdateScript(c *gin.Context) {
giteaHost := "gitea.hostxtra.co.uk"
@@ -878,16 +877,12 @@ func saveSettings(c *gin.Context) {
c.JSON(http.StatusOK, SavedResponse{Saved: true})
}
// handleInstallScript godoc
//
// @Summary Agent install script (Linux)
// @Description Dynamically generated shell script that downloads, verifies and installs the agent, seeded with a pre-registration token.
// @Tags install
// @Produce plain
// @Param server_id query string true "Server ID"
// @Param token query string true "Pre-registration token"
// @Success 200 {string} string "shell script"
// @Router /install [get]
// handleInstallScript serves a dynamically generated shell script that
// downloads, verifies and installs the agent, seeded with a pre-registration
// token. Deliberately not in the generated OpenAPI document, for the same
// reason as handleUpdateScript: it is registered on the bare engine, outside
// the /api group the document's BasePath assumes, so a @Router annotation
// would publish a /api/install path that 404s.
func handleInstallScript(c *gin.Context) {
serverID := c.Query("server_id")
token := c.Query("token")
+27 -1
View File
@@ -56,7 +56,7 @@ func listTokenScopes(c *gin.Context) {
// createToken godoc
//
// @Summary Create an API token
// @Description The plaintext token is returned exactly once and stored nowhere. A token's role and scopes cannot exceed the creator's own.
// @Description The plaintext token is returned exactly once and stored nowhere. A token's role cannot exceed the creator's own; when the request is itself token-authenticated, its scopes cannot exceed the calling token's scopes either.
// @Tags tokens
// @Accept json
// @Produce json
@@ -82,6 +82,32 @@ func createToken(c *gin.Context) {
return
}
// A token-authenticated request may only mint a token whose scopes are a
// subset of its own. Role is capped against the creating *user* below (in
// services.CreateAPIToken), but a role cap alone does not confine scopes —
// without this, a CI token holding only settings:write could mint a token
// holding keys:write and secrets:write, since minting only ever required
// settings:write and never checked what the caller itself could reach. A
// cookie session skips this: its authority is the user's role, not a
// scope list.
if auth.IsToken(c) {
callerScopes := auth.Scopes(c)
var excess []string
for _, s := range body.Scopes {
if !services.ScopeSatisfied(callerScopes, s) {
excess = append(excess, s)
}
}
if len(excess) > 0 {
c.JSON(http.StatusForbidden, gin.H{
"error": fmt.Sprintf("requested scopes exceed the calling token's own scopes: %v", excess),
"code": "scope_confinement",
"excess_scopes": excess,
})
return
}
}
tok, plaintext, err := services.CreateAPIToken(
auth.InstanceID(c), auth.UserID(c),
body.Name, body.Role, body.Scopes, body.ExpiresInDays, c.ClientIP(),
+7 -3
View File
@@ -135,9 +135,13 @@ func sessionFromToken(c *gin.Context) (*Session, bool) {
tok, err := services.ResolveAPIToken(raw)
if errors.Is(err, services.ErrTokenExpired) {
// Recorded rather than only refused: an expired token still being
// presented is how a forgotten CI job becomes visible.
services.LogEvent(tok.InstanceID, "token.expired_use", tok.Name, "", "",
fmt.Sprintf("expired token '%s' was used", tok.Name))
// presented is how a forgotten CI job becomes visible. Throttled to
// once per token per minute, or a looping job writes an unbounded
// stream of audit rows instead of one.
if services.ShouldLogExpiredTokenUse(tok.TokenID) {
services.LogEvent(tok.InstanceID, "token.expired_use", tok.Name, "", "",
fmt.Sprintf("expired token '%s' was used", tok.Name))
}
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "token expired", "code": "token_expired"})
return nil, false
}
+38
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"strings"
"sync"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
@@ -123,6 +124,9 @@ func CreateAPIToken(instanceID, userID, name, role string, scopes []string, expi
InstanceID: instanceID,
UserID: userID,
Name: name,
// Hint is "vt_" plus 5 hex characters of the secret (20 bits) — enough
// for a user to recognise their own token in a list, not enough to be
// useful to anyone who only has the hint. Considered and accepted.
Hint: plaintext[:8],
TokenHash: HashToken(plaintext),
Role: role,
@@ -181,6 +185,40 @@ func TouchAPIToken(tok *models.APIToken) {
tok.LastUsedAt = &now
}
var (
expiredTokenAuditMu sync.Mutex
expiredTokenAuditSeen = map[string]time.Time{}
)
// ShouldLogExpiredTokenUse reports whether an expired token's use is worth a
// fresh audit row, throttled to once per token per minute — the same window
// TouchAPIToken uses for last-used, kept here rather than in the auth package
// because the storage concern (what counts as "recent") belongs beside the
// token's other storage-backed state, not scattered into the request layer.
//
// Without this, a looping CI job presenting one expired token writes an
// unbounded stream of token.expired_use audit rows (and a Mongo FindOne per
// request), drowning the real audit trail. The first use per window is still
// recorded: that is what turns a forgotten job into something visible, rather
// than silencing it entirely.
//
// This is in-memory and per-process, which is a deliberate choice matching
// TouchAPIToken: it degrades to "up to once per minute per replica" rather
// than needing a shared store, and undercounting a security-relevant audit
// signal is the safe direction to err in.
func ShouldLogExpiredTokenUse(tokenID string) bool {
now := time.Now().UTC()
expiredTokenAuditMu.Lock()
defer expiredTokenAuditMu.Unlock()
if last, ok := expiredTokenAuditSeen[tokenID]; ok && now.Sub(last) < time.Minute {
return false
}
expiredTokenAuditSeen[tokenID] = now
return true
}
// ListAPITokens returns a user's own tokens, or every token in the instance
// when all is true. The caller decides whether all is permitted.
func ListAPITokens(instanceID string, userID string, all bool) ([]models.APIToken, error) {
+12
View File
@@ -0,0 +1,12 @@
"use client";
import { ApiKeysPanel } from "@/components/apikeys/ApiKeysPanel";
/**
* Reachable at every role, unlike /settings. Any member may mint and revoke
* their own API keys — the API has never required owner or admin for that —
* and owner and admin additionally see every key in the instance.
*/
export default function ApiKeysPage() {
return <ApiKeysPanel />;
}
+23 -10
View File
@@ -10,7 +10,6 @@ import { Field } from "@/components/settings/Field";
import { Group } from "@/components/settings/Group";
import { SectionCard } from "@/components/settings/SectionCard";
import { MembersCard } from "@/components/settings/MembersCard";
import { ApiTokensCard } from "@/components/settings/ApiTokensCard";
import { AuthProvidersCard } from "@/components/settings/AuthProvidersCard";
const numberInputClass =
@@ -224,7 +223,6 @@ export default function SettingsPage() {
<div className="space-y-10">
<Group label="Access">
<MembersCard />
<ApiTokensCard />
<AuthProvidersCard
localLoginEnabled={settings?.local_login_enabled ?? true}
onLocalLoginChange={(v) => {
@@ -297,14 +295,29 @@ export default function SettingsPage() {
<input type="number" min={0} value={logRetentionDays} onChange={(e) => setLogRetentionDays(Number(e.target.value))} className={numberInputClass} />
</Field>
<div className="mt-6">
<Field
label="Maximum API token lifetime (days)"
hint="0 means no cap, and tokens may be created with no expiry. Changing this affects new tokens only."
>
<input type="number" min={0} value={apiTokenMaxDays} onChange={(e) => setApiTokenMaxDays(Number(e.target.value))} className={numberInputClass} />
</Field>
</div>
</SectionCard>
{/* The cap lives here rather than on /api-keys because it is
instance policy, not one person's credentials — which is
also what lets that page be reachable at every role. */}
<SectionCard
title="API keys"
description="Issuance policy for the keys people create to call the REST API."
icon={<KeyIcon />}
>
<Field
label="Maximum API key lifetime (days)"
hint="0 means no cap, and keys may be created with no expiry. Changing this affects new keys only — existing keys keep working and are flagged for rotation."
>
<input type="number" min={0} value={apiTokenMaxDays} onChange={(e) => setApiTokenMaxDays(Number(e.target.value))} className={numberInputClass} />
</Field>
<p className="mt-4 text-sm text-text-secondary">
Keys themselves are managed on{" "}
<Link href="/api-keys" className="text-accent hover:underline">
API Keys
</Link>
, which every member can reach.
</p>
</SectionCard>
</div>
+101 -38
View File
@@ -16,6 +16,16 @@ interface NavItem {
adminOnly?: boolean;
}
/**
* A labelled run of nav items. Grouping is by what the operator is doing, not
* by which API serves the page: credentials sit together under Access whether
* they are SSH keys, vault secrets or API keys.
*/
interface NavGroup {
label: string;
items: NavItem[];
}
function ServerIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
@@ -145,18 +155,54 @@ function WorkloadIcon() {
);
}
const navItems: NavItem[] = [
{ href: "/servers", label: "Servers", icon: <ServerIcon /> },
{ href: "/monitors", label: "Monitors", icon: <MonitorIcon /> },
{ href: "/vulnerabilities", label: "Vulnerabilities", icon: <ShieldIcon /> },
{ href: "/workloads", label: "Workloads", icon: <WorkloadIcon /> },
{ href: "/keys", label: "SSH Keys", icon: <KeyIcon /> },
{ href: "/secrets", label: "Secrets", icon: <SecretIcon /> },
{ href: "/workflows", label: "Workflows", icon: <WorkflowIcon /> },
{ href: "/steps", label: "Steps", icon: <StepsIcon /> },
{ href: "/audit", label: "Audit Log", icon: <AuditIcon /> },
{ href: "/settings/license", label: "Licence", icon: <LicenceIcon />, adminOnly: true },
{ href: "/settings", label: "Settings", icon: <SettingsIcon />, adminOnly: true },
function TokenIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M14.25 9.75L16.5 12l-2.25 2.25m-4.5 0L7.5 12l2.25-2.25M6 20.25h12A2.25 2.25 0 0020.25 18V6A2.25 2.25 0 0018 3.75H6A2.25 2.25 0 003.75 6v12A2.25 2.25 0 006 20.25z"
/>
</svg>
);
}
const navGroups: NavGroup[] = [
{
label: "Fleet",
items: [
{ href: "/servers", label: "Servers", icon: <ServerIcon /> },
{ href: "/workloads", label: "Workloads", icon: <WorkloadIcon /> },
{ href: "/monitors", label: "Monitors", icon: <MonitorIcon /> },
{ href: "/vulnerabilities", label: "Vulnerabilities", icon: <ShieldIcon /> },
],
},
{
label: "Access",
items: [
{ href: "/keys", label: "SSH Keys", icon: <KeyIcon /> },
{ href: "/secrets", label: "Secrets", icon: <SecretIcon /> },
// Not adminOnly: the API lets any member mint and revoke their own
// keys, capped at their own role, so gating the page would hide a
// capability they have.
{ href: "/api-keys", label: "API Keys", icon: <TokenIcon /> },
],
},
{
label: "Automation",
items: [
{ href: "/workflows", label: "Workflows", icon: <WorkflowIcon /> },
{ href: "/steps", label: "Steps", icon: <StepsIcon /> },
],
},
{
label: "Instance",
items: [
{ href: "/audit", label: "Audit Log", icon: <AuditIcon /> },
{ href: "/settings/license", label: "Licence", icon: <LicenceIcon />, adminOnly: true },
{ href: "/settings", label: "Settings", icon: <SettingsIcon />, adminOnly: true },
],
},
];
/** Shared by the permanent aside and the offcanvas drawer one copy of the nav. */
@@ -164,7 +210,14 @@ export function SidebarContent({ onNavigate }: { onNavigate?: () => void }) {
const pathname = usePathname();
const { user, instance, isAdmin } = useAuth();
const visibleItems = navItems.filter((item) => !item.adminOnly || isAdmin);
// A group whose every item is admin-only disappears entirely for a member,
// heading and rule included — an empty labelled section reads as something
// that failed to load.
const visibleGroups = navGroups
.map((group) => ({ ...group, items: group.items.filter((item) => !item.adminOnly || isAdmin) }))
.filter((group) => group.items.length > 0);
const visibleItems = visibleGroups.flatMap((group) => group.items);
const activeHref = visibleItems.reduce<string | null>((best, item) => {
const matches = pathname === item.href || pathname.startsWith(item.href + "/");
@@ -190,31 +243,41 @@ export function SidebarContent({ onNavigate }: { onNavigate?: () => void }) {
</div>
<nav className="flex-1 overflow-y-auto px-3 py-4">
<ul className="space-y-1">
{visibleItems.map((item) => {
const isActive = activeHref === item.href;
return (
<li key={item.href}>
<Link
href={item.href}
onClick={onNavigate}
// The active marker is an accent bar, the same device
// site/ uses to mark the chosen plan. A filled pill
// reads as a button you can press again.
className={clsx(
"relative flex items-center gap-3 rounded px-3 py-2.5 text-sm transition-colors",
isActive
? "bg-surface-2 font-semibold text-text-primary before:absolute before:inset-y-1 before:left-0 before:w-[2px] before:rounded-full before:bg-accent before:content-['']"
: "font-medium text-text-secondary hover:bg-surface-2 hover:text-text-primary",
)}
>
{item.icon}
{item.label}
</Link>
</li>
);
})}
</ul>
{visibleGroups.map((group, groupIndex) => (
<div
key={group.label}
// The heading terminates the group above it, so the rule
// goes on top and the first group needs none.
className={clsx(groupIndex > 0 && "mt-4 border-t border-border pt-4")}
>
<p className="px-3 pb-1.5 font-mono text-[0.68rem] uppercase tracking-[0.1em] text-text-secondary">{group.label}</p>
<ul className="space-y-1">
{group.items.map((item) => {
const isActive = activeHref === item.href;
return (
<li key={item.href}>
<Link
href={item.href}
onClick={onNavigate}
// The active marker is an accent bar, the same device
// site/ uses to mark the chosen plan. A filled pill
// reads as a button you can press again.
className={clsx(
"relative flex items-center gap-3 rounded px-3 py-2.5 text-sm transition-colors",
isActive
? "bg-surface-2 font-semibold text-text-primary before:absolute before:inset-y-1 before:left-0 before:w-[2px] before:rounded-full before:bg-accent before:content-['']"
: "font-medium text-text-secondary hover:bg-surface-2 hover:text-text-primary",
)}
>
{item.icon}
{item.label}
</Link>
</li>
);
})}
</ul>
</div>
))}
</nav>
<div className="shrink-0 border-t border-border px-4 py-3">
@@ -4,9 +4,25 @@ import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, type ApiToken, type Role } from "@/lib/api";
import { useAuth } from "@/components/AuthProvider";
import { Badge, Button, ConfirmDialog, Modal, Table, Tbody, Td, Th, Thead, Tr, friendlyMessage, useToast } from "@/components/ui";
import { Field, inputClass } from "./Field";
import { SectionCard } from "./SectionCard";
import {
AsyncBoundary,
Badge,
Button,
Card,
ConfirmDialog,
EmptyState,
Modal,
Table,
TableSkeleton,
Tbody,
Td,
Th,
Thead,
Tr,
friendlyMessage,
useToast,
} from "@/components/ui";
import { Field, inputClass } from "@/components/settings/Field";
const ROLES: Role[] = ["owner", "admin", "member"];
@@ -24,18 +40,6 @@ const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
* confirmation message name a token rather than a token_id. */
type PendingRevoke = { id: string; name: string };
function TokenIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M14.25 9.75L16.5 12l-2.25 2.25m-4.5 0L7.5 12l2.25-2.25M6 20.25h12A2.25 2.25 0 0020.25 18V6A2.25 2.25 0 0018 3.75H6A2.25 2.25 0 003.75 6v12A2.25 2.25 0 006 20.25z"
/>
</svg>
);
}
function roleVariant(role: Role) {
if (role === "owner") return "accent" as const;
if (role === "admin") return "warning" as const;
@@ -47,6 +51,43 @@ function rolesAtOrBelow(role: Role): Role[] {
return idx === -1 ? ROLES : ROLES.slice(idx);
}
/**
* Collapses ["servers:read","servers:write","keys:read"] into one chip per
* resource carrying its access. Sixteen scopes rendered as sixteen badges make
* the row taller than everything around it and still have to be read one at a
* time; the resource is what a person scans for, and r/w is the qualifier.
*/
function summariseScopes(scopes: string[]): { resource: string; access: string }[] {
const byResource = new Map<string, { read: boolean; write: boolean }>();
for (const scope of scopes) {
const [resource, action] = scope.split(":");
const entry = byResource.get(resource) ?? { read: false, write: false };
if (action === "read") entry.read = true;
if (action === "write") entry.write = true;
byResource.set(resource, entry);
}
return Array.from(byResource, ([resource, { read, write }]) => ({
resource,
// write implies read on the server, so a token holding only :write is
// still shown as rw rather than pretending it cannot read.
access: write ? "rw" : read ? "r" : "",
}));
}
function ScopeChips({ scopes }: { scopes: string[] }) {
if (scopes.length === 0) return <span className="text-text-secondary"></span>;
return (
<div className="flex flex-wrap gap-1">
{summariseScopes(scopes).map(({ resource, access }) => (
<Badge key={resource} variant="neutral">
{resource}
<span className="ml-1 font-mono text-[0.65rem] uppercase tracking-[0.08em] opacity-70">{access}</span>
</Badge>
))}
</div>
);
}
/** Renders a token's expiry, plus a policy note when the cap has tightened
* since the token was issued. The policy is not applied retroactively, so an
* outside-policy token is a prompt to rotate, not a failure of any kind. */
@@ -76,7 +117,15 @@ function ExpiryCell({ token, capDays }: { token: ApiToken; capDays: number }) {
);
}
export function ApiTokensCard() {
/**
* The whole API Keys page body, header included.
*
* It is a page rather than a card on /settings because any member may mint and
* revoke their own keys the API has never required owner or admin for that
* while /settings is owner|admin throughout. The instance-wide lifetime cap
* stays on /settings, being policy rather than one person's credentials.
*/
export function ApiKeysPanel() {
const queryClient = useQueryClient();
const { user, isAdmin } = useAuth();
const toast = useToast();
@@ -173,97 +222,130 @@ export function ApiTokensCard() {
const assignableRoles = user ? rolesAtOrBelow(user.role) : ROLES;
const count = tokens?.length ?? 0;
return (
<SectionCard
title="API tokens"
description="Scoped, personal tokens for scripts and CI to call the REST API without a browser session."
icon={<TokenIcon />}
actions={
<div className="flex items-center gap-3">
{isAdmin && (
<label className="flex items-center gap-1.5 text-xs text-text-secondary">
<input
type="checkbox"
checked={showAll}
onChange={(e) => setShowAll(e.target.checked)}
className="h-4 w-4 rounded border-border bg-surface-2 accent-accent"
/>
All tokens
</label>
)}
<Button variant="primary" size="sm" onClick={() => setCreateOpen(true)}>
Create token
</Button>
<div className="p-4 sm:p-6 lg:p-8">
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary">API Keys</h1>
<p className="mt-1 text-sm text-text-secondary">
{count} key{count !== 1 ? "s" : ""} · {showAll ? "instance-wide" : "yours"}
</p>
</div>
}
>
{isLoading ? (
<div className="flex justify-center py-8">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-border border-t-accent" />
<Button variant="primary" onClick={() => setCreateOpen(true)}>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
New key
</Button>
</div>
{/* Owner and admin can see everyone's keys, so the scope of the list
is a filter rather than a preference the same pill treatment
the vulnerabilities page uses for its state filter, so a person
who has learned one has learned both. */}
{isAdmin && (
<div className="mb-4 flex flex-wrap items-center gap-2">
{[
{ label: "My keys", all: false },
{ label: "All keys", all: true },
].map((option) => (
<button
key={option.label}
type="button"
onClick={() => setShowAll(option.all)}
aria-pressed={showAll === option.all}
className={`rounded-lg border px-3 py-1.5 text-sm transition-colors ${
showAll === option.all ? "border-accent text-accent" : "border-border text-text-secondary hover:text-text-primary"
}`}
>
{option.label}
</button>
))}
</div>
) : error ? (
<p className="py-6 text-sm text-danger">{friendlyMessage(error)}</p>
) : !tokens || tokens.length === 0 ? (
<p className="py-6 text-sm text-text-secondary">No API tokens yet.</p>
) : (
<Table>
<Thead>
<Tr>
<Th>Name</Th>
{showAll && <Th>Owner</Th>}
<Th>Role</Th>
<Th>Scopes</Th>
<Th>Last used</Th>
<Th>Expires</Th>
<Th className="text-right">Actions</Th>
</Tr>
</Thead>
<Tbody>
{tokens.map((t) => (
<Tr key={t.token_id}>
<Td label="Name">
<span className="font-medium text-text-primary">{t.name}</span>
<div className="font-mono text-xs text-text-secondary">{t.hint}</div>
</Td>
{showAll && <Td label="Owner" className="text-text-secondary">{t.user_email ?? t.user_id}</Td>}
<Td label="Role">
<Badge variant={roleVariant(t.role)}>{t.role}</Badge>
</Td>
<Td label="Scopes">
<div className="flex flex-wrap gap-1">
{t.scopes.map((s) => (
<Badge key={s} variant="neutral">
{s}
</Badge>
))}
</div>
</Td>
<Td label="Last used" className="text-text-secondary">
{t.last_used_at ? new Date(t.last_used_at).toLocaleString() : "Never"}
</Td>
<Td label="Expires">
<ExpiryCell token={t} capDays={capDays} />
</Td>
<Td label="Actions" className="text-right">
<Button
variant="ghost"
size="sm"
className="text-danger hover:text-danger"
onClick={() => setRevoking({ id: t.token_id, name: t.name })}
>
Revoke<span className="sr-only"> {t.name}</span>
</Button>
</Td>
</Tr>
))}
</Tbody>
</Table>
)}
<Card padding={false}>
<AsyncBoundary
isLoading={isLoading}
error={error}
skeleton={<TableSkeleton columns={showAll ? 7 : 6} />}
isEmpty={count === 0}
empty={
<EmptyState
title={showAll ? "No API keys in this instance." : "You have no API keys."}
description={
showAll
? "Nobody has created a key yet. Keys let scripts and CI call the REST API without a browser session."
: "Create one to call the REST API from a script or a CI job. It is scoped to what you grant it and never exceeds your own role."
}
icon={
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M14.25 9.75L16.5 12l-2.25 2.25m-4.5 0L7.5 12l2.25-2.25M6 20.25h12A2.25 2.25 0 0020.25 18V6A2.25 2.25 0 0018 3.75H6A2.25 2.25 0 003.75 6v12A2.25 2.25 0 006 20.25z"
/>
</svg>
}
action={{ label: "Create your first key", onClick: () => setCreateOpen(true) }}
/>
}
>
<Table>
<Thead>
<Tr>
<Th>Name</Th>
{showAll && <Th>Owner</Th>}
<Th>Role</Th>
<Th>Scopes</Th>
<Th>Last used</Th>
<Th>Expires</Th>
<Th className="text-right">Actions</Th>
</Tr>
</Thead>
<Tbody>
{tokens?.map((t) => (
<Tr key={t.token_id}>
<Td label="Name">
<span className="font-medium text-text-primary">{t.name}</span>
<div className="font-mono text-xs text-text-secondary">{t.hint}</div>
</Td>
{showAll && <Td label="Owner" className="text-text-secondary">{t.user_email ?? t.user_id}</Td>}
<Td label="Role">
<Badge variant={roleVariant(t.role)}>{t.role}</Badge>
</Td>
<Td label="Scopes">
<ScopeChips scopes={t.scopes} />
</Td>
<Td label="Last used" className="text-text-secondary">
{t.last_used_at ? new Date(t.last_used_at).toLocaleString() : <span className="text-text-secondary/70">Never used</span>}
</Td>
<Td label="Expires">
<ExpiryCell token={t} capDays={capDays} />
</Td>
<Td label="Actions" className="text-right">
<Button
variant="ghost"
size="sm"
className="text-danger hover:text-danger"
onClick={() => setRevoking({ id: t.token_id, name: t.name })}
>
Revoke<span className="sr-only"> {t.name}</span>
</Button>
</Td>
</Tr>
))}
</Tbody>
</Table>
</AsyncBoundary>
</Card>
<ConfirmDialog
open={revoking !== null}
title="Revoke token"
confirmLabel="Revoke token"
title="Revoke key"
confirmLabel="Revoke key"
loading={isRevoking}
error={revokeError ? friendlyMessage(revokeError) : null}
onClose={() => {
@@ -279,20 +361,36 @@ export function ApiTokensCard() {
}
/>
<Modal open={createOpen} title={result ? "Token created" : "Create API token"} onClose={closeCreate}>
<Modal open={createOpen} title={result ? "Key created" : "New API key"} onClose={closeCreate}>
{result ? (
<div className="space-y-4">
<p className="text-sm text-text-secondary">This is the only time this token will be shown. Store it now.</p>
<div className="flex items-center gap-2">
<code className="flex-1 overflow-x-auto rounded bg-well p-3 font-mono text-sm break-all text-text-primary">{result.token}</code>
<div className="rounded border border-warning/30 bg-warning/10 px-3 py-2 text-sm text-warning">
This is the only time <span className="font-semibold">{result.record.name}</span> is shown. Copy it now Vantage stores only a
hash and cannot show it again.
</div>
<code className="block overflow-x-auto rounded bg-well p-3 font-mono text-sm break-all text-text-primary">{result.token}</code>
<dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<dt className="text-text-secondary">Role</dt>
<dd className="text-text-primary">{result.record.role}</dd>
<dt className="text-text-secondary">Scopes</dt>
<dd>
<ScopeChips scopes={result.record.scopes} />
</dd>
<dt className="text-text-secondary">Expires</dt>
<dd className="text-text-primary">
{result.record.expires_at ? new Date(result.record.expires_at).toLocaleDateString() : "Never"}
</dd>
</dl>
{/* Copy is the primary action, not Done: the value is
unrecoverable once this closes, so the button that
saves it should be the one under the pointer. */}
<div className="flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={copyToken}>
{copied ? "Copied!" : "Copy"}
</Button>
<Button type="button" variant="primary" onClick={closeCreate}>
<Button type="button" variant="ghost" onClick={closeCreate}>
Done
</Button>
<Button type="button" variant="primary" onClick={copyToken}>
{copied ? "Copied" : "Copy key"}
</Button>
</div>
</div>
) : (
@@ -303,7 +401,7 @@ export function ApiTokensCard() {
}}
className="space-y-4"
>
<Field label="Name" hint="A short label identifying what will use this token, e.g. the CI pipeline or the script.">
<Field label="Name" hint="A short label identifying what will use this key, e.g. the CI pipeline or the script.">
<input required value={name} onChange={(e) => setName(e.target.value)} className={inputClass} />
</Field>
@@ -317,7 +415,7 @@ export function ApiTokensCard() {
</select>
</Field>
<Field label="Scopes" hint="What this token may call. Grant only what the caller actually needs.">
<Field label="Scopes" hint="What this key may call. Grant only what the caller actually needs.">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{resources.map((r) => {
const readScope = `${r}:read`;
@@ -353,7 +451,7 @@ export function ApiTokensCard() {
<Field
label="Expires"
hint={capDays > 0 ? `This instance caps new tokens at ${capDays} days. Options beyond that, and Never, are disabled.` : "Never means the token has no expiry."}
hint={capDays > 0 ? `This instance caps new keys at ${capDays} days. Options beyond that, and Never, are disabled.` : "Never means the key has no expiry."}
>
<select
value={expiryDays === null ? "never" : String(expiryDays)}
@@ -378,12 +476,12 @@ export function ApiTokensCard() {
Cancel
</Button>
<Button type="submit" variant="primary" loading={creating}>
Create token
Create key
</Button>
</div>
</form>
)}
</Modal>
</SectionCard>
</div>
);
}