Compare commits

..
259 Commits
Author SHA1 Message Date
mrhid6 00d4307346 fix: Fixed chart for ingress
Chart Release / chart (push) Successful in 12s
Server Deploy / deploy (push) Successful in 1m40s
2026-08-13 09:29:01 +00:00
mrhid6 7e767ecb4f fix: Move the API keys page off the /api prefix
/api-keys shares a raw string prefix with /api, and the proxies in front
of this app do not all match by path segment. Nginx Proxy Manager routes
/api straight to the Go server with a prefix location, so /api-keys never
reached Next at all — it reached a control plane with no such route and
came back as a JSON 404. Traefik's PathPrefix has the same shape of
matcher, which puts the Helm ingress at risk whenever ingress.api.enabled
is on.

The page is /tokens now, which cannot collide with anything, and which
matches the /api/tokens the REST API already publishes. The sidebar still
says API Keys — the label is for the reader, the path is for the router.

A permanent redirect covers anyone who bookmarked the old path today.
Fixing the proxy config instead would have left the trap set for the next
deployment, and for whatever sits in front of it.
2026-08-13 09:26:34 +00:00
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
mrhid6 4c88d6e768 feat: Publish an OpenAPI 3.1 document and a Scalar reference
Chart Release / chart (push) Successful in 25s
Server Deploy / deploy (push) Successful in 10m17s
Generated from swaggo v2 annotations, committed rather than built into the
image: the runtime stage is scratch and adding codegen puts the toolchain
in the build. CI regenerates and diffs, so an annotation edited without
regenerating fails the build — without that the annotations would drift
while still looking authoritative.

Scalar is vendored rather than loaded from a CDN, because air-gapped
self-hosted installs are supported and a reference page that fails closed
offline is a support ticket.
2026-08-12 15:23:02 +00:00
mrhid6 a85a354e57 feat: Annotate workflow, step, run and workload routes
Same treatment: named types replace gin.H literals, and every handler gets
a swaggo doc block. This is the last of the handler files under
server/internal/api/.
2026-08-12 15:22:54 +00:00
mrhid6 9b18d09d9b feat: Annotate monitor, secrets and vulnerability routes
Same treatment: named types replace gin.H literals, and every handler gets
a swaggo doc block. VulnSummaryResponse uses pointer fields so the
db-freshness block stays entirely absent when no vulndb_meta document
exists yet, matching the handler's original conditional gin.H exactly.
2026-08-12 15:22:51 +00:00
mrhid6 a398da0eac feat: Annotate SSO, channel, console, instance and licence routes
Same treatment as the previous commit: named types replace gin.H literals,
and every handler gets a swaggo doc block.
2026-08-12 15:22:48 +00:00
mrhid6 edb8406e05 feat: Annotate server, key and token routes for OpenAPI
Converts their gin.H responses to the named types added in the previous
commit and adds swaggo doc blocks for every handler in handlers.go and
tokens.go.
2026-08-12 15:22:45 +00:00
mrhid6 bfd185adbb feat: Add OpenAPI response types and top-level swag annotations
Named response types for handlers that were returning anonymous gin.H
literals, so a generated annotation and what the handler actually returns
cannot disagree. main.go carries the top-level swaggo info block (title,
description, security schemes for cookie, bearer token and ESO auth).
2026-08-12 15:22:42 +00:00
mrhid6 182752d9ab feat: Manage API tokens from settings
A card in the Access group beside Members and single sign-on rather than a
new nav entry — /settings/instance was folded back in for exactly this
reason. The plaintext is shown once in a well block and never again.

Tokens outside a newly tightened lifetime policy are flagged rather than
broken, because the policy governs issuance, not existing credentials.
2026-08-12 14:58:57 +00:00
mrhid6 3b4c87a292 feat: Rate limit API token requests
600 per minute per token, in the Redis that sessions already require.
Cookie sessions are untouched. A Redis failure falls through rather than
refusing traffic — it is already a larger problem and should not become a
second outage.
2026-08-12 14:51:22 +00:00
mrhid6 2685e9ad06 fix: Distinguish caller mistakes from backend failures in CreateAPIToken
createToken's catch-all mapped every unmatched error to 400, so a
database outage reported itself as a malformed client request. Wrap the
genuine validation failures with ErrTokenInvalid and let the handler
answer 500 with a fixed message for everything else.
2026-08-12 14:48:17 +00:00
mrhid6 4de67e4bea docs: Separate caller mistakes from backend failures in the plan
createToken's catch-all answered 400 for every unmatched error, so a
database failure reported itself as the caller's malformed request. Found
in review of Task 7.
2026-08-12 14:47:36 +00:00
mrhid6 524ccc6412 feat: Add the API token endpoints
Create, list and revoke, with no update: editing what a credential already
deployed in CI can do, with no record of what it could do before, is worse
than requiring a rotation. Revoking a token that is not yours answers
not-found, since a 403 confirms it exists.

The audit actor stays the human and names the credential alongside, so a
person clicking and their CI job are told apart.
2026-08-12 14:44:50 +00:00
mrhid6 be4f488db3 feat: Enforce API token scopes from the route map
Keyed on the registered gin route pattern rather than a per-route
decorator, because a route registered without a decorator would be
unguarded. An unmapped route reached by a token is a 403, and a boot-time
check refuses to start when any /api route is missing, so the failure
lands at deploy rather than as a customer's surprise 403.
2026-08-12 14:40:00 +00:00
mrhid6 2f60b81962 fix: Stop the session middleware writing two responses on an expired cookie
sessionFromCookie already writes "session expired" when a cookie was
presented and rejected with no bearer to fall through to. Middleware
called sessionFromToken anyway, which wrote a second "not authenticated"
body onto the same response for every ordinary browser-session timeout -
gin logged "superfluous response.WriteHeader call" on ordinary use, not a
rare edge case.

Guard on c.IsAborted() after sessionFromCookie: true only in that one
rejected-cookie-no-bearer branch, so it short-circuits there while the
other three credential paths (no credential, bearer only, stale cookie
plus valid bearer) are unaffected.
2026-08-12 14:35:20 +00:00
mrhid6 92692de94d docs: Correct the middleware fallback in the plan
The plan's Middleware called sessionFromToken even when sessionFromCookie
had already answered a rejected cookie, putting two JSON bodies on the
wire for the ordinary expired-session case. Found in review of Task 5.
2026-08-12 14:34:54 +00:00
mrhid6 a5f9fca59e feat: Authenticate the API with a bearer token as well as a cookie
One middleware, two ways to arrive at the same *Session, so every handler,
role guard, licence gate and audit call is untouched. The host guard
applies to both: a token carries an instance, and the tenant boundary must
not have a token-shaped hole in it.

The effective role is min(user, token) recomputed per request, so demoting
somebody demotes their tokens with them. A stale cookie beside a valid
bearer falls through rather than refusing a credential that would work.
2026-08-12 14:32:40 +00:00
mrhid6 72e5228351 feat: Add the API token service
Mint, resolve, list and revoke, with the effective role capped at the
owner's and recomputed per request rather than frozen at creation.

Deleting a user deletes their tokens in the same call, so offboarding is
one action. Revoking somebody else's token answers not-found rather than
forbidden, since a 403 confirms the credential exists.

Also re-exports shared.APITokenMaxDays into server/internal/models,
following the existing ValidRole wrapper pattern, since the token
service needs it and it was never re-exported.
2026-08-12 14:28:10 +00:00
mrhid6 33b5ec0788 feat: Add a per-instance API token lifetime cap
A pointer with absent meaning no cap, so an upgrade allows never-expire
tokens exactly as before and an instance opts into the policy. It governs
issuance only: changing it never invalidates a token that already exists.
2026-08-12 14:22:12 +00:00
mrhid6 1b718e7c59 feat: Define the API token scope vocabulary
Eight resources with read and write, write implying read. Coarse on
purpose: a scope per endpoint is a table nobody maintains, and a route
added without an entry either breaks or is unguarded.
2026-08-12 14:19:47 +00:00
mrhid6 6ad65a1242 feat: Add the api_tokens collection and its indexes
The unique index on token_hash is what makes authentication an indexed
lookup rather than a scan, so this builder is fatal on failure like
EnsureAuthIndexes rather than warning like the secrets one.

Registered in ScopedCollections so instance purge reaches it.
2026-08-12 14:16:12 +00:00
mrhid6 a41f2b26cc docs: Plan the API token and OpenAPI implementation
Twelve tasks from model through middleware, scope enforcement, endpoints,
web UI, generated OpenAPI and documentation. Verification is build plus
curl and UI checks rather than test cycles, matching a repository with no
Go test harness beyond shared/mail.
2026-08-12 14:09:03 +00:00
mrhid6 71f9a9dca5 docs: Specify scoped API tokens and an OpenAPI reference
Adds the approved design for personal access tokens on the control plane
REST API, and for the generated OpenAPI 3.1 document served as a Scalar
reference page.

Tokens fall back into the existing session middleware rather than getting
their own route group, so every handler, role guard and audit call works
unchanged. Scope enforcement derives from the registered route pattern and
fails closed, with a boot-time check for unmapped routes.

A Terraform provider is deliberately left to a follow-on spec.
2026-08-12 13:59:03 +00:00
mrhid6 449684ceaa fix: Derive the rename unwind deadline at its use site
Chart Release / chart (push) Successful in 17s
Server Deploy / deploy (push) Successful in 5m58s
2026-08-12 11:15:29 +00:00
mrhid6 cdc50b7aaf fix: Harden instance rename against interleaving and lost unwinds 2026-08-12 11:08:36 +00:00
mrhid6 f534b74066 docs: Document instance rename in HQ 2026-08-12 10:52:43 +00:00
mrhid6 3c15ee15ef feat: Let staff rename an instance 2026-08-12 10:29:45 +00:00
mrhid6 45a4f968d6 feat: Let a customer rename a cloud instance from HQ 2026-08-12 10:23:17 +00:00
mrhid6 df09e42cf2 feat: Add rename calls and slug preview to the HQ client 2026-08-12 10:17:39 +00:00
mrhid6 ee427ed6e1 feat: Add staff instance rename endpoint 2026-08-12 10:12:04 +00:00
mrhid6 5856deede3 feat: Add customer instance rename endpoint 2026-08-12 10:09:19 +00:00
mrhid6 e02b263054 feat: Add rename cooldown field and cloudprov rename 2026-08-12 10:06:31 +00:00
mrhid6 1d6c89c368 feat: Add instance rename to shared provisioning 2026-08-12 10:01:56 +00:00
mrhid6 0edfddb710 docs: Drop test steps from the rename plan 2026-08-12 09:58:18 +00:00
mrhid6 71a4f53bed docs: Implementation plan for instance rename in HQ 2026-08-12 09:53:40 +00:00
mrhid6 de7350fce9 docs: Reuse loginURLFor for the rename response host 2026-08-12 09:46:38 +00:00
mrhid6 21786fa1a8 docs: Design for instance rename in Vantage HQ 2026-08-12 09:46:08 +00:00
mrhid6 84f9587b9a fix: Fixed gitignore
Chart Release / chart (push) Successful in 13s
Server Deploy / deploy (push) Successful in 25s
2026-08-11 15:03:03 +00:00
mrhid6 a20e165ac2 feat: Revamp instance page
Chart Release / chart (push) Successful in 22s
Server Deploy / deploy (push) Successful in 37s
2026-08-11 10:32:59 +01:00
mrhid6 ed260cd86c feat: Updated members panel on instance page
Chart Release / chart (push) Successful in 10s
Server Deploy / deploy (push) Successful in 1m24s
2026-08-11 10:10:44 +01:00
mrhid6 99cd2a8ec3 feat: Updated admin instance page
Chart Release / chart (push) Successful in 19s
Server Deploy / deploy (push) Successful in 41s
2026-08-11 09:59:27 +01:00
mrhid6 a228ab24a0 feat: Changes to self hosted purchase
Chart Release / chart (push) Successful in 12s
Server Deploy / deploy (push) Successful in 9m18s
2026-08-11 09:30:19 +01:00
mrhid6 3acbf01d46 docs: Self review of doc pages
Chart Release / chart (push) Successful in 18s
Server Deploy / deploy (push) Successful in 3m24s
2026-08-10 16:32:23 +01:00
mrhid6 675689a458 feat(audit): server-side paging, search and category filter; one event format
Chart Release / chart (push) Successful in 12s
Server Deploy / deploy (push) Successful in 8m2s
The page rendered a map of eleven event types to labels and seven to colours.
The server emits forty-seven. Everything unmapped fell through to its raw
string, so "Key Assigned" in green sat above "workflow.schedule_updated" in
grey — the same kind of fact in two formats, which made the column look like it
carried a meaning it did not.

Presentation is now derived rather than enumerated. Event types are named
<category>.<action> by every call site, so the category becomes a chip, the
action is humanised, and the tone comes from the verb. A type added to the
server tomorrow gets a sensible label and colour with no second list to update;
the override table holds only the dozen the rule reads badly for. Every row is
one treatment, and colour never carries meaning alone — the sentence beside it
says the same thing in words.

Paging and filtering are server-side, unlike the fleet lists that answer with
everything and slice in the browser. audit_retention_days is a licensed
entitlement measured in months, and this log is read to answer questions about
the past, so a browser filtering the most recent page would report "no results"
for events that exist. GET /api/audit now takes q, category, limit and skip and
answers {events, total} — a short page is not evidence of the end of the log,
which is why the total is counted rather than inferred.

audit_logs had no indexes at all: every read was a collection scan with an
in-memory sort over an append-only collection. Adds (instance_id, created_at)
and warns rather than failing, matching EnsureSecretIndexes.

Two bugs found by running the deriver over all forty-seven real types rather
than eyeballing it: the tone rules matched only past-tense verbs, leaving
auth_provider.delete drawn as neutral beside key.deleted in red; and
"unaccepted" matched "accepted", so withdrawing an acceptance read as the same
caution as granting one.
2026-08-10 15:25:48 +01:00
mrhid6 42f3f3e640 feat(web): typed confirmation for deleting an SSH key
Chart Release / chart (push) Successful in 14s
Server Deploy / deploy (push) Successful in 1m37s
The last of the inline two-step deletes, and the one with the most reach: a
key delete revokes it from every server at once, and for a generated key the
stored private half goes with it. That copy is the only one Vantage holds, so
unlike an uploaded key this cannot be undone by pasting the public half back.

Body names how many servers lose access, and says the private key is destroyed
only when there is one to destroy.

The delete error moves out of the toast and into the dialog, which stays open
on failure, matching the server and monitor deletes.
2026-08-10 15:07:52 +01:00
mrhid6 21238fe707 feat(web): typed confirmations on destructive actions; toasts for API outcomes
Chart Release / chart (push) Successful in 22s
Server Deploy / deploy (push) Successful in 48s
Destructive actions were confirmed by a second danger button rendered where
the first one had been, so a double click on Remove deleted the thing without
the operator ever reading which thing it was. Servers, monitors, secret keys
and sign-in providers now go through ConfirmDialog with requireTyped, matching
the secret-group delete that already worked this way. Notification channels and
vulnerability alert rules get an untyped dialog: both are a name and a URL and
are rebuilt in a minute, but neither had any confirmation at all, and both
silently stop alerts that nobody misses until an incident goes unannounced.

Mutations otherwise succeeded in silence, or reported into whatever inline
banner the page happened to own. Two failure modes came of that: a modal that
closed on error left the message nowhere to land, and a save that was rejected
left the old values on screen looking exactly like a save that worked
(/settings had no error path at all). Every mutation now reports through the
existing toast context.

Errors stay inline where the surface that raised them is still on screen and
the message is a correction to make in it: form validation, the cron field,
the tag rows, a rejected licence blob, and the workflow designer's autosave,
which is a standing condition rather than an event. Everything else toasts.

Ad-hoc feedback removed in favour of it: the "Sent!" button labels on the
server maintenance tab, the settings "Saved!" flag, the channel test line, and
the steps page's notice/error pair.
2026-08-10 14:26:02 +01:00
mrhid6 ef86ef04a1 fix: Fixes to command stream
Chart Release / chart (push) Successful in 11s
Server Deploy / deploy (push) Successful in 2m36s
2026-08-10 14:07:17 +01:00
mrhid6 727bb09eff feat: More updates to hq
Chart Release / chart (push) Successful in 12s
Server Deploy / deploy (push) Failing after 1m11s
2026-08-10 13:56:59 +01:00
mrhid6 1fe4ba5999 feat: HQ redesign
Chart Release / chart (push) Successful in 24s
Server Deploy / deploy (push) Successful in 1m19s
2026-08-10 10:44:30 +01:00
mrhid6 e434beec7a fix(web): toasts survive an open dialog; empty-state actions can be gated
Both from the final review, and the first one overturns a call I got wrong.

The aria-hidden sweep that makes aria-modal true also swallowed the toasts.
ToastProvider renders inside the app root, and every modal-raised confirmation
is toasted *before* its dialog closes — "Saved …", "Deleted …", "Removed …" —
so each one was inserted into a hidden subtree and never announced. Un-hiding
a live region afterwards does not replay what it missed. The toast layer is
portalled to the body carrying the dialog-layer attribute, which exempts it
from the sweep, and sits above the dialog: a toast explaining why a dialog's
action failed is no use behind it.

EmptyState's action was narrower than the call site it replaced. The old
first-workflow button carried loading={isPending}; the new one carried
nothing, so a double click created two workflows. The action is a union now —
a link takes no pending state, a handler takes loading and disabled.
2026-08-10 10:01:47 +01:00
mrhid6 fe1dfe472a refactor(web): list pages share the async primitives; dialogs hide the page behind
Keys, secrets, audit, steps and workflows each carried the same
loading/error/empty ternary with its own copy of the spinner and its own
wording for a failed fetch — five of them had drifted to five different
sentences for "the request did not come back". They go through AsyncBoundary
now, which means a skeleton in place of a spinner, a retry button on failure,
and backend messages passed through friendlyMessage rather than printed raw.

Steps gets the filtered-empty state the fleet just got: "no steps match that
filter" is not "no steps yet", and only one of them should offer to create the
first one.

Two more from review:

Modal's effect ran before `mounted` flipped, so a dialog rendered already open
found null refs and took no focus at all. It depends on `mounted` now.

aria-modal was a claim with no mechanism behind it — portalled to the body,
the app tree is a sibling of the dialog and a screen reader's virtual cursor
still browsed the page underneath. The body's other children are marked
aria-hidden while any dialog is open, refcounted alongside the scroll lock.
This does mean a toast raised while a dialog is open is not announced, which
is the correct trade for a modal: ConfirmDialog shows its own errors inline.
2026-08-10 09:54:32 +01:00
mrhid6 0cfaf6670c fix(web): address review of the dialog, toast and async primitives
Two of these were real defects in the previous two commits.

friendlyMessage discarded exactly the messages it claimed to keep: the
"is this a bare reason phrase" test was a shape regex, and "Default steps
cannot be edited" has the same shape as "Not Found". It is an exact-match set
of reason phrases now.

Modal depended on onKeyDown, which is rebuilt whenever onClose changes
identity — and onClose is an inline arrow at every call site, so any parent
re-render (a 30s poll, a mutation flipping to pending) tore the effect down
and rebuilt it: cleanup restored focus to the trigger, setup then moved it to
the top of the dialog, mid-typing. onClose is held in a ref and the effect is
keyed on `open` alone.

Also in Modal: initial focus takes the first control in the body rather than
the panel, since the header comes first in DOM order and every dialog was
opening on its own dismiss button; the Tab trap pulls focus back when it has
escaped the panel entirely rather than only handling the two ends; the scroll
lock is refcounted, because per-instance save/restore released the page when
an outer dialog unmounted under an open inner one; and the whole thing is
portalled to the body so a nested confirm is not clipped by its parent's
overflow box.

The fleet's filtered-empty state keyed on the search alone, so a tag filter
matching nothing told a customer with a full fleet to add their first server.

Remaining: pending mutation errors are reset when a confirm dialog closes, so
one member's failure no longer greets the next; ConfirmDialog clears typed
confirmation when the target changes, not only when it reopens; deleting a
workflow closes its dialogs before navigating rather than carrying a scroll
lock onto the next page; toasts split into a polite and an assertive region,
since one polite wrapper demotes the role="alert" children inside it; and a
custom skeleton gets a live "Loading" beside it, having been aria-hidden with
nothing else to announce.
2026-08-10 09:44:29 +01:00
mrhid6 4d67341ba5 feat(web): fleet search and sort, shared empty/error states, no background polling
The fleet list had a tag filter and nothing else: no search, no sort, and an
unbounded list. Searching hostname/address/OS and sorting by hostname, status
or last seen are all client-side, since the browser already holds the fleet
the page just fetched. Sorting by status orders by how much attention each
state wants rather than alphabetically, which is the only reason to sort by it.

The filtered count is shown beside the total so a search does not read as the
fleet having shrunk, and "no results" is a distinct empty state from "no
servers", with a way back out of the search.

refetchIntervalInBackground defaults to false on the query client. Polling
pages kept refetching in a hidden tab — the fleet list pulls inventory blobs
every 30s — so a console left open in a background tab polled until its
session expired. It belongs in the defaults because the argument is identical
on every polling page.
2026-08-10 09:35:39 +01:00
mrhid6 1fa9160c59 fix(web,adminsite): accessible dialogs, real confirmations, shared async UI
Four correctness/accessibility defects and the destructive-action flow.

- Button: the loading spinner carried xmlns="http://www.w3.instance/2000/svg",
  a find/replace of "org" that landed inside a URL. Button also grows an href
  form, because <Link><Button> nested a button inside an anchor at nineteen
  call sites: invalid markup, two tab stops, and Enter firing only the anchor.

- Fleet status was four meanings carried by hue with the distinction living in
  a title attribute, which touch never shows and screen readers need not
  announce. It now carries a text label and an accessible name, which is the
  one rule the design system states outright.

- Modal had no focus management at all: no trap, no initial focus, no restore,
  no scroll lock, no aria-labelledby. Dialogs nest (a confirm over an edit), so
  a stack decides which panel owns Escape and Tab.

- Seven destructive actions went through window.confirm(). ConfirmDialog
  replaces them and can say what is about to happen; deleting a secret group,
  a shared base step or a workflow now requires typing the name, since those
  have no undo and a wide blast radius. adminsite keeps its own inline idiom
  rather than importing a dialog system it does not have.

Adds Toast, AsyncBoundary/EmptyState/ErrorState/TableSkeleton and
friendlyMessage, replacing per-page loading ternaries and raw
(error as Error).message text. Wired here only where a call site was already
being edited; the remaining pages follow.
2026-08-10 09:25:53 +01:00
mrhid6 d559cccd44 feat: Updated server page
Chart Release / chart (push) Successful in 22s
Server Deploy / deploy (push) Successful in 45s
2026-08-07 16:21:17 +01:00
mrhid6 0684d84609 fix: Fixed vuln score
Chart Release / chart (push) Successful in 24s
Server Deploy / deploy (push) Successful in 2m40s
2026-08-07 15:33:50 +01:00
mrhid6 78f1bf853c fix: More fixes to vuln matching
Chart Release / chart (push) Successful in 31s
Server Deploy / deploy (push) Successful in 1m42s
2026-08-07 13:29:15 +01:00
mrhid6 e28238191d feat: Added vuln filter
Chart Release / chart (push) Successful in 25s
Server Deploy / deploy (push) Successful in 2m29s
2026-08-07 11:58:42 +01:00
mrhid6 82bcc5776f fix: Fixed vuln scanning
Chart Release / chart (push) Successful in 15s
Server Deploy / deploy (push) Successful in 4m7s
2026-08-07 11:13:58 +01:00
mrhid6 1993802c38 feat: Updated rescan button text 2026-08-07 10:52:16 +01:00
mrhid6 5db49b6b0e feat: Vuln debug logs
Chart Release / chart (push) Successful in 11s
Server Deploy / deploy (push) Successful in 1m47s
2026-08-07 10:50:08 +01:00
mrhid6 0c15b25ecd fix: Fixed agent package version
Server Deploy / deploy (push) Successful in 14s
Chart Release / chart (push) Successful in 26s
Agent Release / build (push) Successful in 11m32s
Agent Release / msi (push) Successful in 1m9s
2026-08-07 10:21:00 +01:00
mrhid6 0c21765da3 feat: Added pagination
Chart Release / chart (push) Successful in 13s
Server Deploy / deploy (push) Successful in 1m39s
2026-08-07 09:59:50 +01:00
mrhid6 4ff8fc8d51 docs: document the workload registry
Chart Release / chart (push) Successful in 11s
Server Deploy / deploy (push) Successful in 8m45s
Agent Release / build (push) Successful in 10m54s
Agent Release / msi (push) Successful in 2m31s
2026-08-07 09:09:18 +01:00
mrhid6 483053b9a2 feat: workload registry UI 2026-08-07 09:06:36 +01:00
mrhid6 fd4c51f3db feat: workload registry REST API 2026-08-07 09:01:46 +01:00
mrhid6 1b351cfca4 feat: agent reports workloads and handles workload commands 2026-08-07 08:58:46 +01:00
mrhid6 cf9d85b3cd feat: store workload reports and route log results 2026-08-07 08:56:26 +01:00
mrhid6 6a4ef5b6c6 feat: workload registry proto messages 2026-08-07 08:53:33 +01:00
mrhid6 501cf4e733 feat: agent reads bounded workload logs 2026-08-07 08:49:42 +01:00
mrhid6 89c21d752a feat: agent control actions with self-protection 2026-08-07 08:48:27 +01:00
mrhid6 0e38d9d500 feat: agent enumerates systemd services 2026-08-07 08:46:59 +01:00
mrhid6 3511c34daa feat: agent enumerates docker containers 2026-08-07 08:46:03 +01:00
mrhid6 0838d1d735 feat: models and indexes for the workload registry 2026-08-07 08:45:00 +01:00
mrhid6 d1769fc886 feat: Updated vuln style
Chart Release / chart (push) Successful in 12s
Server Deploy / deploy (push) Successful in 1m36s
2026-08-06 16:35:04 +01:00
mrhid6 6dced22499 fix: Fixed agent collect packages
Chart Release / chart (push) Successful in 11s
Server Deploy / deploy (push) Successful in 21s
Agent Release / build (push) Successful in 58s
Agent Release / msi (push) Successful in 1m34s
2026-08-06 16:20:34 +01:00
mrhid6 5cee53dc5f feat: Better button description
Chart Release / chart (push) Successful in 20s
Server Deploy / deploy (push) Successful in 2m23s
2026-08-06 15:51:18 +01:00
mrhid6 81248bb159 style: bring the vulnerabilities page onto the house page shape
Every other page under app/(app) opens with `p-4 sm:p-6 lg:p-8` and the
layout adds none of its own, so this page alone sat flush against the shell
edge. Its h1 was text-xl where every other page is text-2xl.

The findings list was a stack of separately bordered cards; it is now rows
inside one Card, separated by border-border-soft, matching the monitors and
workflows lists. Loading is the shared spinner rather than a line of text,
the error is the shared danger strip, and the empty state uses the same
proportions as the monitors one.
2026-08-06 15:49:39 +01:00
mrhid6 6354d54de8 feat: added addon price to pricing page 2026-08-06 15:48:20 +01:00
mrhid6 da6d64f95c fix: give the scratch server image a /tmp for the vulnerability database
The runtime stage is FROM scratch, which has no /tmp, so vulnsched died at
startup with "temp dir: stat /tmp: no such file or directory" and no scan
ever ran. Nothing in the server wrote to a temporary directory before the
trivy-db puller, which is why this only appeared now.

scratch cannot mkdir its own, so the directory is staged in the builder at
1777 and copied in. Also corrects CLAUDE.md, which described this image as
Alpine; the time/tzdata import it justifies is if anything more load-bearing
on scratch.
2026-08-06 15:46:30 +01:00
mrhid6 9ba3d4a61f feat: Vulnerability Scanning feature on license page
Chart Release / chart (push) Successful in 12s
Server Deploy / deploy (push) Successful in 1m20s
2026-08-06 15:40:13 +01:00
mrhid6 eee236a072 fix: tidy server go.mod for the vulnerability database dependencies
Chart Release / chart (push) Successful in 12s
Server Deploy / deploy (push) Canceled after 2m11s
trivy-db, oras-go, the three version comparators and robfig/cron were
recorded as indirect with an incomplete go.sum, which builds locally
against a warm module cache but fails in CI with "updates to go.mod
needed". trivy-db pulls testify into the build graph, and its hashes
were missing entirely.
2026-08-06 15:18:57 +01:00
mrhid6 9df89e2db4 fix: surface vuln_scanning across licence, staff and pricing UI
Chart Release / chart (push) Successful in 11s
Server Deploy / deploy (push) Canceled after 59s
The catalogue row alone was not enough; the feature was invisible in
three places and mislabelled in a fourth.

PlanConfigurator rendered every key that was not "console" as "Single
sign-on", so the staff checkbox granting vulnerability scanning was
labelled single sign-on. Feature wording was duplicated between the staff
configurator and the purchase form and the copies had drifted, so it now
lives in adminsite/lib/features.ts and both read from it.

The customer licence panel showed raw keys; it now labels them.

Pricing gains a comparison row. The add-on block with a monthly price is
deliberately NOT added: that is a pricing decision, and the Paddle price
IDs for the new catalogue rows have to be pasted in before it can be sold
anyway.
2026-08-06 15:10:26 +01:00
mrhid6 f60c509b47 feat: vuln_scanning entitlement and documentation
Chart Release / chart (push) Successful in 12s
Server Deploy / deploy (push) Failing after 1m11s
Agent Release / build (push) Successful in 1m0s
Agent Release / msi (push) Successful in 2m18s
Adds license.FeatureVulnScanning as the one name for the feature and a
catalogue row per deployment/tier, following console and oidc: features
are opt-in per customer, so no plan bundles it.

Documents the subsystem in CLAUDE.md, including that ScopedCollections is
the canonical registry instance deletion derives from — there is no
separate deletion list, which the plan had wrong.
2026-08-06 14:44:11 +01:00
mrhid6 84dfcfeac7 feat: vulnerability findings UI
Fleet board grouped by CVE, a per-server section on server detail, and
alert rules beside the channels they consume.

The server detail page has no tab pattern despite the plan saying to
follow one, so this adds a section in the existing vertical stack.

Three states are kept visually distinct because they are identical if
handled carelessly and only one is good news: never reported, no advisory
feed for the distribution, and scanned-and-clean. Database freshness sits
with the findings rather than in settings for the same reason.
2026-08-06 14:40:37 +01:00
mrhid6 5dda3b5c4a feat: vulnerability scanning pipeline, matcher, scheduler and API
Completes tasks 10-15 and fixes what was outstanding:

- vulndb.Pull implemented with oras-go, streaming the ~50MB layer and
  staging both files before replacing either, so a failed pull leaves the
  previous database intact rather than a half-written one.
- db.go: Vulnerability.Severity is a string, not trivy Severity, so the
  int conversion did not compile. Severity now resolves vendor (highest
  when vendors disagree) then NVD then unknown, and CVSS is read too.
- findings.go: added sweepFixedFindings plus the fleet query, severity
  counts, rescan flag and accept/unaccept the API needs.
- vulnrules.go: added rule CRUD and the digest builder. ResolveTargets
  returns []models.Server, not []string, so filterByServers was wrong.
- api/vulnerabilities.go was an empty file while handlers.go registered
  twelve routes against it; written, grouped by CVE.
- shared/mail: added the missing sender. The templates were orphaned and
  the HTML one was a copy of the text one, defining "subject" (which
  html/template would escape) and emitting no markup. render.go parses
  every template in init(), so a bad one panics server, admin and sitesvc
  at boot — go build never runs init(), which is why nothing complained.
- notify: digests dispatch through their own path so SMTP gets the digest
  template rather than arriving dressed as a monitor alert.
2026-08-06 14:33:46 +01:00
mrhid6 db64320bd8 feat: agent reports installed packages on the hourly loop
SyncKeys now returns the whole response so the poll can carry
CollectPackages; a separate RPC for one boolean would be a message every
30 seconds for a value that changes when a licence does.

The flag is an atomic: the 30s poll writes it, the hourly package loop
reads it, and they are different goroutines.
2026-08-06 13:21:13 +01:00
mrhid6 583f60771c feat: store agent package reports and serve the collect flag
VulnScanningEnabled reads GetLicenseState(...).Feature("vuln_scanning")
and requires an active licence, never switching on tier. ReportPackages
re-checks it server-side: the agent flag is the optimisation, this is
the boundary.
2026-08-06 13:19:39 +01:00
mrhid6 a92c3190c2 feat: ReportPackages wire types with hash short-circuit
The pb packages are hand-written, not protoc-generated, and the wire
codec is JSON (encoding.RegisterCodec(JSONCodec{})). Field numbers in
the .proto are documentation; JSON field names are the contract. Both pb
packages edited by hand to match.

SyncResponse.collect_packages is omitempty and absent decodes as false,
so an older server leaves agents collecting nothing rather than
collecting without a licence.
2026-08-06 13:17:44 +01:00
mrhid6 3a6d24fe0e feat: models and indexes for package inventory and CVE findings
Adds server_packages, vuln_findings and vuln_alert_rules to
ScopedCollections rather than to a separate deletion list. purgeInstance
derives its collection list from that registry, so instance deletion
follows automatically and there is no second copy to drift.
2026-08-06 11:59:00 +01:00
mrhid6 c277ecff44 feat: agent collects installed packages per package manager 2026-08-06 11:56:41 +01:00
mrhid6 bd690c94c3 feat: agent parses /etc/os-release for distro identification 2026-08-06 11:55:45 +01:00
mrhid6 a22fdf197e feat: map OS family and version to trivy-db advisory buckets 2026-08-06 11:55:01 +01:00
mrhid6 bd24b03cac feat: version comparators for distro package ordering 2026-08-06 11:54:13 +01:00
mrhid6 3afc4ab012 docs: workload registry plan; remove tests from both plans
Both plans now verify by build, vet and manual checks written into the
tasks. Spec verification sections updated to match so they no longer
describe tests that will not be written.
2026-08-06 11:27:37 +01:00
mrhid6 d1ac3e98ce docs: design for the workload registry
Agents enumerate Docker containers, compose stacks and systemd services;
start/stop/restart and bounded log snapshots from the UI.

Sub-project B, Linux only. Live log following stays in the console.
2026-08-06 11:11:26 +01:00
mrhid6 5bba54f3e5 fix: Fixed style layout on workflow run page 2026-08-06 10:50:31 +01:00
mrhid6 fe7bc300e2 docs: implementation plan for package inventory and CVE findings
17 tasks, TDD where the logic is pure. Corrects two spec claims:
the server reads features via License.HasFeature rather than admin's
entitlement directly, and shared/mail/render_test.go does not exist.
2026-08-06 10:49:10 +01:00
mrhid6 00c03c365d docs: design for package inventory and CVE findings
Agents report installed packages; the control plane matches them against
trivy-db and raises findings that link to the existing ApplyUpdatesCmd
patching path.

Scoped to sub-project A, Linux only. Container registry, image scanning
and compliance baselines are separate specs.
2026-08-06 10:33:54 +01:00
mrhid6 dc8dd3dd58 fix: Fixed step descriptions
Chart Release / chart (push) Successful in 11s
Server Deploy / deploy (push) Successful in 35s
2026-08-04 17:45:31 +01:00
mrhid6 85a8865892 feat: restyle the steps table and add 22 default steps
Chart Release / chart (push) Successful in 11s
Server Deploy / deploy (push) Successful in 2m46s
2026-08-04 17:34:18 +01:00
mrhid6 50a9ac5fdc fix: count tag-matched servers in the workflows list 2026-08-04 17:28:01 +01:00
mrhid6 3388d2f895 fix: Fixed padding on add step button
Chart Release / chart (push) Successful in 22s
Server Deploy / deploy (push) Successful in 41s
2026-08-04 17:24:40 +01:00
mrhid6 3a77fc2abd feat: edit target servers and tags together in the workflow modal 2026-08-04 17:21:12 +01:00
mrhid6 3d59836d0c feat: dual list box for workflow target servers
Chart Release / chart (push) Successful in 11s
Server Deploy / deploy (push) Successful in 5m19s
2026-08-04 17:13:50 +01:00
mrhid6 d9184312aa fix: schedule card placement, preview state, and scheduled-workflow docs 2026-08-04 17:08:11 +01:00
mrhid6 b9802e6b04 docs: Updated docs 2026-08-04 17:03:29 +01:00
mrhid6 c2635ed51a fix: Fixed schedule workflow col
Chart Release / chart (push) Successful in 20s
Server Deploy / deploy (push) Successful in 41s
2026-08-04 14:42:10 +01:00
mrhid6 b21ac05547 feat: show workflow schedules in the list
Chart Release / chart (push) Successful in 24s
Server Deploy / deploy (push) Successful in 2m34s
2026-08-04 14:16:13 +01:00
mrhid6 484b620867 feat: schedule editor on the workflow page 2026-08-04 14:13:31 +01:00
mrhid6 439bc2ed7d feat: schedule methods on the web api client 2026-08-04 14:10:04 +01:00
mrhid6 a1e6986a64 feat: fire scheduled workflow runs from the housekeeping leader 2026-08-04 13:53:42 +01:00
mrhid6 d0e1cc4ad6 feat: cron arithmetic and persisted workflow schedules 2026-08-04 13:51:10 +01:00
mrhid6 b877024365 docs: server tags and workflow tag targeting 2026-08-04 13:44:16 +01:00
mrhid6 2de7ac116b feat: filter the fleet by tag and target workflows by tag selector 2026-08-04 13:42:47 +01:00
mrhid6 fa1fd14ed1 feat: view and edit server tags 2026-08-04 13:38:42 +01:00
mrhid6 d1b3cd2f74 feat: target workflow runs by tag selector 2026-08-04 13:36:43 +01:00
mrhid6 e00a0da5d9 feat: tag endpoints for servers 2026-08-04 13:34:22 +01:00
mrhid6 fef0b7c7a1 feat: read and write server tags, resolve targets from the database 2026-08-04 13:33:00 +01:00
mrhid6 efd29dc259 feat: parse tag filters and resolve targets as ids union tag selector 2026-08-04 13:31:04 +01:00
mrhid6 13cd41d202 feat: validate server tags and add the model field 2026-08-04 13:30:06 +01:00
mrhid6 3530ce6cb7 docs: implementation plans for server tags and scheduled workflows 2026-08-04 13:26:04 +01:00
mrhid6 09522c2566 docs: design for server tags and scheduled workflows 2026-08-04 13:13:12 +01:00
mrhid6 80f0afb28b feat: Updated monitors pages
Chart Release / chart (push) Successful in 13s
Server Deploy / deploy (push) Successful in 1m25s
2026-08-04 12:19:15 +01:00
mrhid6 287bd9657b fix: Fixed paddle relink sub
Chart Release / chart (push) Successful in 27s
Server Deploy / deploy (push) Successful in 1m17s
2026-08-03 17:34:39 +01:00
mrhid6 b5f684c4fe fix: Fixed paddle subs
Chart Release / chart (push) Successful in 28s
Server Deploy / deploy (push) Successful in 1m17s
2026-08-03 15:32:01 +01:00
mrhid6 1f08e90009 feat: Removed email alert settings
Chart Release / chart (push) Successful in 10s
Server Deploy / deploy (push) Successful in 5m15s
2026-08-03 14:40:15 +01:00
mrhid6 6881d92d0a fix: local-login toggle no longer reverts unsaved settings edits
Chart Release / chart (push) Successful in 26s
Server Deploy / deploy (push) Successful in 4m8s
server/internal/services/settings.go SaveSettings takes alerts and
email as required (non-pointer) values and writes them unconditionally
- absent fields would blank stored settings, not just leave them
alone. onLocalLoginChange was building its payload from the stale
loaded settings object instead of the in-progress form state
(thresholdMinutes/logRetentionDays) that handleSubmit uses, so editing
the offline threshold and then flipping the toggle silently reverted
the edit. Both paths now submit the same in-progress values.
2026-08-03 14:13:05 +01:00
mrhid6 5e016c6584 fix: audit ack_notice and stop misreporting DB errors as lockouts
ackAuthProviderNotice mutated callback_notice with no audit event; it
now writes auth_provider.ack_notice like create/update/delete.

guardProviderChange's callers turned any error from
CountEnabledAuthProviders into a 409 last_provider, so a transient
Mongo error was reported to the operator as an unremovable lockout.
Only services.ErrLockout now produces the 409; anything else is a 500.
2026-08-03 14:12:21 +01:00
mrhid6 537b8758ff fix: purge auth_providers when reaping an instance
auth_providers was missing from ScopedCollections, so reap.go's
scopedCollectionsForPurge() (derived from that list) never deleted an
instance's providers, leaving orphaned rows holding encrypted client
secrets forever. Verified migration 0004's $rename over org_id->instance_id
is a no-op here since auth_providers never carried org_id.
2026-08-03 14:11:42 +01:00
mrhid6 c03360333b fix: single source of truth for local-login lockout rescue
HandleLocalLogin and HandleListPublicProviders each computed their own
answer to whether password sign-in must stay available, and they could
disagree: an instance with local login off and a licence that lapses
loses its only provider and its password form in the same moment, with
no endpoint left to recover. services.LocalLoginPermitted is now the
one predicate both call.
2026-08-03 14:11:22 +01:00
mrhid6 fa7c5d341d docs: fix stale auth-provider references in rest-api and licensing docs 2026-08-03 11:08:22 +01:00
mrhid6 b6fc8c3f77 docs: document multiple auth providers and the callback URL change 2026-08-03 11:05:40 +01:00
mrhid6 37f2c1457e feat: manage multiple sign-in providers from settings 2026-08-03 11:00:25 +01:00
mrhid6 3a626922a5 feat: render one login button per configured auth provider 2026-08-03 10:56:03 +01:00
mrhid6 dde47de145 feat: auth provider REST API and public provider discovery 2026-08-03 10:51:35 +01:00
mrhid6 f3b9f6f286 feat: add GitHub OAuth2 provider branch 2026-08-03 10:48:19 +01:00
mrhid6 f1c3f67864 feat: per-provider SSO start and callback routes 2026-08-03 10:45:23 +01:00
mrhid6 8f5873afca refactor: carry provider id in the OIDC state token 2026-08-03 10:41:30 +01:00
mrhid6 e22faebfcd feat: migrate instance_oidc into auth_providers (0005) 2026-08-03 10:38:52 +01:00
mrhid6 e2b01b62a5 feat: add local_login_enabled setting with absent-means-on default 2026-08-03 10:36:48 +01:00
mrhid6 0858693d57 feat: add auth provider service layer and lockout guard 2026-08-03 10:33:36 +01:00
mrhid6 45f7c0c393 feat: add AuthProvider model and identity provider presets 2026-08-03 10:30:21 +01:00
mrhid6 c56bfb7270 docs: implementation plan for multiple auth providers 2026-08-03 10:23:35 +01:00
mrhid6 eb45072031 feat: Removed unused test units 2026-08-03 10:18:01 +01:00
mrhid6 1e2132c1a1 docs: Cleanup old specs and plans 2026-08-03 10:15:54 +01:00
mrhid6 19ef773690 docs: drop legacy OIDC callback from multi-provider design 2026-08-03 10:13:26 +01:00
mrhid6 c5aae0614a docs: design for multiple auth providers 2026-08-03 10:09:13 +01:00
mrhid6 17d97aaf52 feat: More logging for command stream
Chart Release / chart (push) Successful in 12s
Server Deploy / deploy (push) Successful in 1m24s
Agent Release / build (push) Successful in 10m37s
Agent Release / msi (push) Successful in 36s
2026-07-31 17:20:35 +01:00
mrhid6 1fb9bd827f feat: Added ping command
Chart Release / chart (push) Successful in 18s
Agent Release / build (push) Successful in 39s
Server Deploy / deploy (push) Successful in 55s
Agent Release / msi (push) Successful in 40s
2026-07-31 17:10:59 +01:00
mrhid6 8699dc5b7e fix: Renew presence on sub/pub
Chart Release / chart (push) Successful in 18s
Server Deploy / deploy (push) Successful in 56s
2026-07-31 16:58:50 +01:00
mrhid6 71240f183c fix: Fixes to server shutdown stream
Chart Release / chart (push) Successful in 21s
Server Deploy / deploy (push) Successful in 1m2s
Agent Release / build (push) Successful in 43s
Agent Release / msi (push) Successful in 49s
2026-07-31 16:44:47 +01:00
mrhid6 01e8b0ba44 feat: Better debugging for console
Chart Release / chart (push) Successful in 11s
Server Deploy / deploy (push) Successful in 1m25s
2026-07-31 16:31:19 +01:00
mrhid6 2aa4784518 feat: Better debugging for console
Chart Release / chart (push) Successful in 18s
Server Deploy / deploy (push) Successful in 59s
2026-07-31 16:13:51 +01:00
mrhid6 f611cae438 feat: Better debugging for console
Chart Release / chart (push) Successful in 11s
Server Deploy / deploy (push) Successful in 1m22s
2026-07-31 16:00:58 +01:00
mrhid6 1eb98ef962 feat: Better debugging for console
Chart Release / chart (push) Successful in 12s
Server Deploy / deploy (push) Successful in 1m22s
2026-07-31 15:51:26 +01:00
mrhid6 6f86496f10 fix: Ffixes to console
Chart Release / chart (push) Successful in 18s
Server Deploy / deploy (push) Successful in 1m9s
2026-07-31 15:05:21 +01:00
mrhid6 57a9b18102 fix: Guacd connection ip
Server Deploy / deploy (push) Successful in 9s
Chart Release / chart (push) Successful in 11s
2026-07-31 14:52:20 +01:00
mrhid6 36995fa62b fix: Fixed install and update scripts
Chart Release / chart (push) Successful in 9s
Server Deploy / deploy (push) Successful in 1m20s
2026-07-31 12:10:32 +01:00
mrhid6 9121fc461f fix: Fixed chart api routes for update
Server Deploy / deploy (push) Successful in 15s
Chart Release / chart (push) Successful in 10s
2026-07-31 12:03:45 +01:00
mrhid6 fc56bae5f9 chore: Bump chart version
Chart Release / chart (push) Successful in 18s
Server Deploy / deploy (push) Successful in 8s
Agent Release / build (push) Successful in 38s
Agent Release / msi (push) Successful in 57s
2026-07-31 11:53:08 +01:00
mrhid6 ac75b3ef76 feat: chart deployment Type added
Chart Release / chart (push) Successful in 20s
Server Deploy / deploy (push) Successful in 35s
2026-07-31 11:52:31 +01:00
mrhid6 e6fe463216 feat: Updated for api ingress routes
Chart Release / chart (push) Successful in 25s
Server Deploy / deploy (push) Successful in 4m26s
2026-07-31 11:21:15 +01:00
mrhid6 8528f14ed7 feat: Added ingress to chart
Chart Release / chart (push) Successful in 10s
Server Deploy / deploy (push) Successful in 1m24s
2026-07-31 10:49:20 +01:00
mrhid6 df1d9658f5 fix: Chart build
Chart Release / chart (push) Successful in 10s
Server Deploy / deploy (push) Successful in 1m20s
2026-07-31 10:41:41 +01:00
mrhid6 9f9b384481 fix: Fixed chart version
Chart Release / chart (push) Failing after 13s
2026-07-31 10:37:04 +01:00
mrhid6 165114471f fix: Fixes to running on kubernetes
Chart Release / chart (push) Failing after 13s
Server Deploy / deploy (push) Successful in 6m35s
2026-07-31 10:34:10 +01:00
mrhid6 de78688093 feat: authenticate the server's Redis connection
InitRedis now takes a username and password, read from REDIS_USERNAME and
REDIS_PASSWORD, matching what admin has always done. Both empty keeps an
unauthenticated Redis working; a password with an empty username is what a
legacy requirepass instance needs, since go-redis then sends AUTH with one
argument instead of two.

This is what lets a Kubernetes install point at a managed Redis instead of
the bundled one.
2026-07-31 09:36:31 +01:00
mrhid6 bbf9f72fd3 feat: Docker and helm charts
Server Deploy / deploy (push) Successful in 5m26s
Agent Release / build (push) Successful in 10m45s
Agent Release / msi (push) Successful in 1m31s
2026-07-31 09:28:54 +01:00
mrhid6 978b665aa6 fix: stop local relay teardown from logging a spurious proxy_failed reason
Session.Close now closing its own accepted conn (from the prior fix wave)
made net.ErrClosed on the guacd-side reader indistinguishable from a real
remote failure, so a normal browser-tab close could race the handler's
defer and intermittently log console.proxy_failed on a healthy session.
Add a closing flag, set before Close's sync.Once body actually tears
anything down, that setReason respects -- a deliberate local teardown can
no longer produce or race in a failure reason, while Close's own explicit
reason argument still wins normally.
2026-07-31 09:25:27 +01:00
mrhid6 1fe608f531 fix: bound and complete console relay teardown, restore proxy_failed audit
- Arm the unclaimed-relay watchdog in NewSession rather than Serve, so an
  agent that never opens its ProxyStream is bounded to 10s and reports
  reason "agent_timeout", per the design spec's failure-mode table.
- Session.Close now also closes the accepted net.Conn (stored via setConn),
  so ConsoleProxy.Close() is an unconditional kill of the whole relay chain
  instead of only closing an already-idle listener.
- Emit console.proxy_failed and end the console session from a defer in
  consoleTunnel guarded on relay.Reason(), since guac's OnDisconnect never
  runs when the connect callback errors -- which is the path every relay
  failure this feature introduces takes. Update the two docsite
  troubleshooting rows to match what the audit event can now actually show.
2026-07-31 09:21:07 +01:00
mrhid6 1e1546cb60 docs: document the agent-relayed console proxy
Every console session now rides the agent's outbound gRPC connection
instead of a direct guacd-to-target dial, so it works for servers
behind NAT and now requires a live agent (409 agent_offline
otherwise). Documents PROXY_ADVERTISE_HOST / PROXY_LISTEN_HOST and
corrects reachability claims across the docsite and CLAUDE.md.
2026-07-31 09:10:05 +01:00
mrhid6 119d8694d1 feat: Reap admin free instance license 2026-07-30 14:42:06 +01:00
mrhid6 8d43c689f5 feat: Reap admin free instance 2026-07-30 14:31:43 +01:00
mrhid6 05f10ed3c9 feat: record relay proxy_id and port in console audit events 2026-07-29 13:10:58 +01:00
mrhid6 c0bec3737b feat: route every console session through the agent relay 2026-07-29 13:07:30 +01:00
mrhid6 59d147fe4d feat: handle OpenProxyCmd in the agent command stream 2026-07-29 13:03:02 +01:00
mrhid6 9e38a01e3d feat: add agent-side console relay 2026-07-29 12:59:24 +01:00
mrhid6 20a302f84a feat: add OpenConsoleProxy service facade 2026-07-29 12:55:36 +01:00
mrhid6 ba2e263d00 fix: collapse ProxyStream auth failures into one indistinguishable response 2026-07-29 12:52:53 +01:00
mrhid6 a000703199 feat: add ProxyStream handler with scoped single-use auth 2026-07-29 12:50:20 +01:00
mrhid6 8fcda63742 fix: avoid closing proxy relay listener before validating remote source 2026-07-29 12:47:35 +01:00
mrhid6 3363ac9dad feat: add console proxy session relay 2026-07-29 12:43:04 +01:00
mrhid6 a7e338b171 feat: add console proxy session registry 2026-07-29 12:40:22 +01:00
mrhid6 bc79daab48 feat: add ProxyStream wire types for agent-relayed console 2026-07-29 12:37:37 +01:00
mrhid6 d3d8dba3ff docs: Implementation plan for agent-relayed console proxy 2026-07-29 12:26:16 +01:00
mrhid6 6d047e25ab docs: Design for agent-relayed console proxy 2026-07-29 12:16:45 +01:00
mrhid6 ed4c39650c feat: Removed hq signup page
Server Deploy / deploy (push) Successful in 1m44s
2026-07-29 10:42:51 +01:00
mrhid6 7b8fa4a8a0 feat: Updated hq login page
Server Deploy / deploy (push) Successful in 40s
2026-07-29 10:35:07 +01:00
mrhid6 8a02c35ec9 docs: Updated docs
Server Deploy / deploy (push) Successful in 55s
2026-07-28 16:54:39 +01:00
mrhid6 487de34a50 docs: Updated docs
Server Deploy / deploy (push) Failing after 1m57s
2026-07-28 16:53:42 +01:00
mrhid6 0424547dd4 docs: Updated docs 2026-07-28 16:53:38 +01:00
mrhid6 5be9ddb2e5 fix: Fixed baked api url
Server Deploy / deploy (push) Successful in 7m8s
2026-07-28 16:18:50 +01:00
mrhid6 bc6c7cdb5a feat: Updated docker file
Server Deploy / deploy (push) Successful in 2m35s
2026-07-28 16:04:51 +01:00
mrhid6 f46fb7fc0e feat: documentation site
Docusaurus 3 docs-only site at docsite/, served statically by nginx under
/docs on the marketing host. Covers getting started (self-hosted install
through first server and first licence), the control plane, Vantage HQ,
a reference section and operations.

Wired into docker-compose.site.yml as docsite (3005:80) and into the
image build workflow, rebuilding on its own directory only. Never added
to the self-hosted compose file.
2026-07-28 15:46:33 +01:00
mrhid6 d9945882e5 docs: serve the documentation site under /docs on the marketing host 2026-07-28 15:22:20 +01:00
mrhid6 9db16522e3 docs: design for the documentation site 2026-07-28 15:20:07 +01:00
mrhid6 6070972f9a chore: updated deps
Server Deploy / deploy (push) Successful in 36s
2026-07-28 15:07:19 +01:00
mrhid6 31e0000306 feat: default steps are read only
Server Deploy / deploy (push) Successful in 1m46s
2026-07-28 13:34:08 +01:00
mrhid6 26567766a9 feat: Move default steps into container 2026-07-28 13:28:43 +01:00
mrhid6 97dc6feaca feat: Tapo platform page
Server Deploy / deploy (push) Successful in 1m30s
2026-07-28 12:57:49 +01:00
mrhid6 f10e61cde4 feat: Updated marketing site 2026-07-28 12:46:28 +01:00
mrhid6 febe48a974 feat: Updated pricing 2026-07-28 12:31:00 +01:00
mrhid6 766dcabfde fix: Fixes to the console focus
Server Deploy / deploy (push) Successful in 43s
2026-07-28 12:02:48 +01:00
mrhid6 c9dab99271 feat: Windows agent inventory
Server Deploy / deploy (push) Successful in 9s
Agent Release / build (push) Successful in 1m39s
Agent Release / msi (push) Successful in 36s
2026-07-28 11:29:08 +01:00
mrhid6 60a4ed9aab fix: Fixed agent msi update command
Server Deploy / deploy (push) Successful in 16s
Agent Release / build (push) Successful in 10m40s
Agent Release / msi (push) Successful in 38s
2026-07-28 10:53:06 +01:00
mrhid6 f56fc2e54d fix: Fixed agent build
Server Deploy / deploy (push) Successful in 4m8s
Agent Release / build (push) Successful in 10m44s
Agent Release / msi (push) Successful in 1m43s
2026-07-28 10:30:29 +01:00
mrhid6 dabe6fe3aa feat: Updated package path to match repo
Agent Release / build (push) Failing after 25s
Agent Release / msi (push) Skipped
Server Deploy / deploy (push) Successful in 2m47s
2026-07-28 10:01:40 +01:00
mrhid6 a232c74990 feat: Move mail system to shared
Server Deploy / deploy (push) Successful in 2m46s
2026-07-28 09:50:46 +01:00
mrhid6 5e326335af fix(web): stop long tokens and button rows overflowing on mobile
Server Deploy / deploy (push) Successful in 1m40s
Three root causes behind five reported overflows:

- Td below sm is a flex row, and a flex item's default min-width:auto is
  its min-content width. A fingerprint or target URL is one unbreakable
  token, so the value could not shrink and spilled out of the card. The
  value now sits in a min-w-0 break-words wrapper that is display:contents
  above sm, leaving the desktop table untouched. Fixes /keys, /monitors
  and the installed-keys table on /servers/[id] in one place.

- The ESO read URL and the secrets group endpoint are unbreakable tokens
  in prose with the default overflow-wrap:normal. Both paragraphs now
  break-words.

- Three page headers (servers/[id], keys/[id], monitors/[id]) held an
  unwrapped button row. They stack below sm and the clusters wrap. These
  used items-start, which is why the earlier responsive sweep — matching
  items-center — skipped all three; only one was reported.

Verified: tsc clean, next build clean, and the compiled stylesheet confirms
max-sm:min-w-0 / max-sm:break-words land in the below-640px query and
sm:contents in the above-640px one.
2026-07-27 23:28:53 +01:00
mrhid6 14a11886ae feat(web): make the control plane usable on mobile
Server Deploy / deploy (push) Successful in 43s
Sidebar becomes a hamburger-driven offcanvas below lg; tables card-stack
below sm via the shared Td primitive's label prop; page padding, modals,
the workflow builder and the remaining fixed layouts all collapse.

Presentation only — no API, route or data-shape changes.

Spec:  docs/superpowers/specs/2026-07-27-web-mobile-responsive-design.md
Plan:  docs/superpowers/plans/2026-07-27-web-mobile-responsive.md
2026-07-27 23:15:25 +01:00
mrhid6 4be7d24aec feat(web): collapse remaining fixed layouts on small screens 2026-07-27 23:01:30 +01:00
mrhid6 dd4ce5bb3e fix(web): scope inspector top border to below lg 2026-07-27 22:57:53 +01:00
mrhid6 5ee3f14eed feat(web): single-column workflow builder below lg 2026-07-27 22:55:07 +01:00
mrhid6 5dcc1bf1be feat(web): bottom-sheet modals and collapsing component grids 2026-07-27 22:50:08 +01:00
mrhid6 facef270b7 feat(web): label table cells for the mobile card stack 2026-07-27 22:46:39 +01:00
mrhid6 e8c75e974d feat(web): responsive page padding and stacking page headers 2026-07-27 22:42:22 +01:00
mrhid6 60af88525c feat(web): offcanvas sidebar with hamburger below lg 2026-07-27 22:33:16 +01:00
mrhid6 74f6dca2f5 feat(web): card-stack tables below sm 2026-07-27 22:28:57 +01:00
mrhid6 effd991c31 docs: mobile responsive design spec and implementation plan for web/ 2026-07-27 22:24:26 +01:00
mrhid6 20e57d19c7 feat: Updated pricing page
Server Deploy / deploy (push) Successful in 2m46s
2026-07-27 22:08:17 +01:00
mrhid6 d5c8d0d0f2 fix: Fixed site mobile width 2026-07-27 21:58:20 +01:00
mrhid6 2869e63d0a feat: Move manage billing button to billing page 2026-07-27 21:47:50 +01:00
mrhid6 792b7eb211 feat: Updated instance record and license blob box
Server Deploy / deploy (push) Successful in 1m13s
2026-07-27 16:48:37 +01:00
mrhid6 8fd5eacec6 feat: Updated self hosted free plans
Server Deploy / deploy (push) Successful in 40s
2026-07-27 16:26:20 +01:00
mrhid6 66140aaf58 fixes
Server Deploy / deploy (push) Successful in 2m24s
2026-07-27 15:59:45 +01:00
mrhid6 fdfe8e8e46 fix: Fixed oidc purchase description
Server Deploy / deploy (push) Successful in 37s
2026-07-27 15:26:34 +01:00
mrhid6 4ad68e3ac4 feat: Updated purchase page
Server Deploy / deploy (push) Successful in 4m40s
2026-07-27 14:59:43 +01:00
mrhid6 0a86167c44 docs: updated docs
Server Deploy / deploy (push) Successful in 1m56s
2026-07-27 10:57:19 +01:00
mrhid6 3537ec59dc docs+deploy: Paddle env, webhook route, collections, CI build args, and spec status 2026-07-27 10:55:41 +01:00
mrhid6andClaude Opus 5 8bbecd2035 feat(adminsite): self-hosted purchase, checkout overlay, billing portal, client line-item builder
Purchase flow: name -> placeholder -> configure via the shipped PlanConfigurator
-> Paddle overlay with custom_data -> paste install UUID to link and issue.
lineItemsFor mirrors the Go catalogue.LineItems/billable exactly (base included
in exactly one place). ManageBillingButton opens the hosted portal. Paddle token
and env are baked into the build, never fetched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 10:53:21 +01:00
mrhid6andClaude Opus 5 c10f093cad feat(admin): awaiting-link reminders, placeholder claim-and-issue, and billing health
sweepAwaitingLink chases paid-but-unlinked self-hosted placeholders at 24h/72h.
claimPlaceholderLink rewrites a placeholder's identity to the install's real
UUID, re-points the subscription, and issues from it — linking and claiming are
one call because the payment already happened. staffBillingHealth surfaces
failed webhook handlers and unlinked paid placeholders.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 10:46:00 +01:00
mrhid6 28b138b4c3 feat(admin): checkout options, self-hosted placeholder, entitlement update, and portal endpoints 2026-07-27 10:43:12 +01:00
mrhid6 01bb37125d feat(admin): renewals issue the next term and collapse a scheduled reduction; payment-failed flags only 2026-07-27 10:41:30 +01:00
mrhid6andClaude Opus 5 2e8114c41e feat(admin): subscription webhooks promote the entitlement and reissue; cancel and past-due take no licence action
created/updated/activated fold into 'make the world match current state', so
out-of-order delivery is correct by construction. A confirmed subscription
promotes desired->granted and signs from granted only. Cancel and past-due touch
only status; the licence runs to expiry. IssueForInstance backstops a
self-hosted placeholder that is linked after payment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 10:40:06 +01:00
mrhid6 6832bfd7bb feat(admin): Paddle webhook ingress — verify, idempotent claim, dispatch 2026-07-27 10:37:53 +01:00
mrhid6andClaude Opus 5 fbd93d0ea5 feat(admin): Paddle client behind an interface, config, and the event idempotency record
Client is a thin REST client (net/http) rather than the vendor SDK: the surface
we need is two calls, and a hand-rolled client has no version-drift risk and no
dependency in go.sum. All Paddle wire shapes live only in http.go.

PADDLE_API_KEY and PADDLE_WEBHOOK_SECRET are boot-required — an unverified
webhook endpoint is one anyone can issue licences through. paddle_events carries
a unique index on event_id for webhook idempotency.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 10:36:26 +01:00
mrhid6andClaude Opus 5 3d3be7465f fix(admin): re-key legacy plans before seeding, tolerant of a prior partial boot
Server Deploy / deploy (push) Successful in 1m5s
Boot ran SeedPlans before Backfill, so the fresh (self_hosted, professional)
seed row was inserted before pass 3 tried to rename the legacy self_hosted TIER
row into it — colliding on deployment_tier_unique and failing boot.

The plan re-key moves to MigrateLegacyPlans, called before SeedPlans so the
rename lands first and the seed no-ops on it. It is also self-healing: on a
database a crashed boot already seeded (self_hosted, professional) into, the
legacy row can no longer be renamed onto it, so it is dropped instead — its
instances are re-tiered and re-entitled from the surviving professional row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 10:03:17 +01:00
mrhid6andClaude Opus 5 c4e6ad5485 feat(license): metered licensing — catalogue, entitlements, and enforcement
Server Deploy / deploy (push) Successful in 5m22s
Implements spec 7 tasks 2-10 on top of the six-plan payload from task 1.

Admin: plans re-keyed on (deployment, tier); new catalogue collection holds
every Paddle price ID (one row per priceable component); new entitlements
collection holds desired beside granted. admin/internal/catalogue owns both
folds — entitlement to licence limits, and entitlement to Paddle line items —
so the base allowance is subtracted in exactly one place. licensing.Issue now
snapshots the instance's granted entitlement, never desired. Free is enforced
per account AND deployment. Staff endpoints for plans, catalogue and
entitlements; Free self-hosted can be claimed and renewed on its annual term;
the reaper stays cloud-only.

Server: enforces the monitor cap, audit-log retention (daily sweep, skips
Unlimited and lapsed instances), and gates the OIDC callback. Unset limits are
filled from the seed plan at the single decode site so old blobs never read as
zero.

Frontends: adminsite gains a catalogue price-ID editor, six-plan allowance
screen, and a catalogue-driven PlanConfigurator mounted on the staff instance
page. web shows monitors, audit retention and support level on the licence page.

Docs: CLAUDE.md, spec index and plan 5 preamble updated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 09:37:39 +01:00
mrhid6andClaude Opus 5 3fc726da9e feat(license): two deployments times three tiers, and two new limits
Plans are keyed on (deployment, tier) rather than tier alone, which is what
ends Free being cloud-only by construction - there is a self-hosted Free
plan now, so the one-per-account rule has to be enforced per deployment
instead of falling out of the plan table.

tier self_hosted becomes a legacy value no new licence carries.
NormaliseTier maps it to self-hosted Professional, which is what it always
granted, so blobs we cannot re-sign keep working.

Limits.FillUnset exists because a licence signed before a field existed
decodes it as 0, and 0 would read as no monitors and an audit log trimmed
to nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 23:41:43 +01:00
mrhid6andClaude Opus 5 73b6b548f0 docs: implementation plan for spec 7, metered licensing
Ten tasks, each ending in something independently verifiable. The plan
stops at the Paddle boundary on purpose: spec 7 lands before plan 5, so
there is no client, no webhook and no checkout here, and the configurator
is built as a component mounted for staff first rather than as a customer
screen with nothing behind it.

Corrects two things the spec got wrong about the control plane. Feature
gating is already built and mounted, so Free tenants have already lost the
console and no customer email is owed. The only real gap is that
HandleOIDCCallback lacks the check HandleOIDCStart already has, which is
the half that completes a sign-in.

Adds the Free self-hosted lifecycle the spec called for and the first draft
of the plan missed: linking issues nothing today, and renewInstance
hardcodes a monthly term that would hand a self-hosted install a one-month
licence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 23:34:34 +01:00
mrhid6andClaude Opus 5 ff0caf5a90 docs: spec 7, metered licensing and the two-by-three plan matrix
Two deployments times three tiers, servers metered per month, console and
SSO opted into individually. plans is re-keyed on (deployment, tier); every
Paddle price ID moves out of plans into a new catalogue collection; a new
entitlements collection holds desired beside granted, and a licence is only
ever signed from granted.

Spec 5's plan is revised rather than followed: it assumes one price per
subscription and a metered plan has three or more. Nothing of it has
shipped, so the revision costs a rewrite of an unstarted plan.

Free stops being cloud-only by construction, which means the plan/instance
deployment comparison in licensing.Issue no longer enforces it and
checkFreeLimit has to count per deployment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 23:16:35 +01:00
mrhid6 87dc9fc858 inital paddle billing docs 2026-07-26 22:57:57 +01:00
mrhid6 dd306e4757 feat(license): the paste form is self-hosted only
Server Deploy / deploy (push) Successful in 2m14s
A cloud instance's licence is issued in HQ and written into the control
plane by admin/internal/inject, straight to the database. The customer has
nothing to paste, so /settings/license offered them a form that could only
ever fail — and on an expired cloud instance, failed at the exact moment
they were looking for a way out.

GET /license now reports `deployment`, and the page swaps the paste form
for a short panel saying where the licence comes from, with a link to the
portal when HQ_URL is set. That is the same treatment hq-managed members
already get in the members table: read-only here, and a pointer to where
it is actually managed.

POST /license refuses with 409 cloud_managed on a cloud deployment. Hiding
a form is a courtesy; this codebase's rule is that the API is the boundary,
and the endpoint was reachable regardless of what the page rendered. It
cannot break injection, which never goes through HTTP at all.

Verified: server builds and vets clean, web builds clean.
2026-07-26 20:42:53 +01:00
mrhid6 fa75d70475 fix(web): rebuild the licence page as the document it is
Server Deploy / deploy (push) Successful in 40s
This screen was never finished. It had no page padding, so it sat flush
against the sidebar while every other screen has p-8; it used raw button,
textarea and file inputs instead of the design system; and it rendered
state as "State: valid" in a sentence.

It also dropped `reason` entirely. An instance with an invalid licence was
told "State: invalid" and nothing else — not that the signature failed, not
that it was issued against a different instance, not what to do. That was
the real defect, and no layout survives having nowhere to put the most
important thing on the page.

A licence is a document: issued, dated, signed, carrying a reference you
quote to support. The page now reads that way, which is also how adminsite/
presents the same object from the issuing side — state as a label with a
shape, the reference on a mono record line, entitlement as keyed fields.

- a record panel opens the page: the state as a statement, the reason
  underneath in this app's own words rather than the API's identifiers,
  then Expires / Source / Instance ID as keyed fields. `source` and
  `days_remaining` were being returned and never shown
- counted limits become meters, because that is already how the console
  shows headroom on a server's disks — same question, same reading. They
  turn amber at 80% and red at the cap
- an unlimited allowance gets no bar. A full-width one would read as "at
  the limit", which is the opposite of what it means
- features render as included/not with a glyph as well as a colour
- the file input is a styled label over a visually hidden input, and now
  reports which file it loaded

Two things found reviewing my own work: Card's p-6 is emitted after p-4, so
`<Card className="p-4">` silently rendered at p-6 — the allowance cards pass
padding={false} instead. And the state had a coloured dot next to a coloured
word on a colour-ruled card, which is one telling too many; the dot is gone.

`Group` moves to components/settings/ so this page and /settings share the
band label rather than growing a second copy.
2026-07-26 20:35:31 +01:00
mrhid6 653bd5a755 refactor(web): members and SSO move onto the settings page
Server Deploy / deploy (push) Successful in 1m12s
/settings/instance held two cards — Members and Single sign-on — behind a
sidebar entry called "Instance", next to one called "Settings". Nothing in
either name told you which held what, and the split left both pages thin.

They are now the Access group at the top of /settings, above Monitoring and
Integrations. Six cards on one page needs sorting into groups or it reads as
a pile, so each group carries site/'s keyed-label eyebrow on a hairline.

The cards move to web/components/settings/ rather than into the page, which
would have made it ~600 lines. That is also where the Field and inputClass
pair now lives: the two pages each had a byte-identical copy, and folding
them together is exactly when three copies would have started to drift.

Members and SSO adopt SectionCard, which the rest of the page already used.
A card that kept its own header treatment would read as a different kind of
thing rather than another setting, which is the problem being fixed.

next.config.ts keeps a permanent redirect from the old path, so bookmarks
and any support reply linking it still land somewhere useful.
2026-07-26 20:19:04 +01:00
mrhid6 01eda1dbb0 feat(web): the console joins the shared design system
Server Deploy / deploy (push) Successful in 1m6s
web/ was the last app on its own palette — a neutral #0f1117 ground with an
indigo accent, unrelated to the logo navy that site/ and adminsite/ are built
on. It now draws from the same tokens, so all three apps are one system.

It stays locked to dark, taking site/'s dark values. That is what keeps
adminsite/'s light default meaningful: an operator with both open tells them
apart by the ground before clicking anything destructive, and now that both
are the same palette, the ground is the only thing left doing that work.

The colour names stay this app's own — text-primary, border, surface rather
than ink, rule, panel — because every screen already reads that way, and
adminsite/ already establishes that each app names the shared tokens after
its own subject.

Tokens are stored as RGB channels with the hex in a trailing comment. The
console leans on Tailwind opacity modifiers far more than the other two
(bg-danger/10, border-accent/50, ring-accent/30), and <alpha-value> only
compiles against channels; the comments keep the three token blocks
diffable by eye.

Beyond colour:
- radii collapse to site/'s 4px in tailwind.config.ts rather than rewriting
  ~140 rounded-lg classes; rounded-full is untouched for dots and pills
- badges become site/'s chip — mono, uppercase, tracked, currentColor rule,
  no fill — keeping their dot so state is never colour alone
- table column heads take the keyed-label idiom, at text-secondary rather
  than tertiary, which lands under 4.5:1 at that size
- sidebar marks the active item with an accent bar, the device site/ uses
  for the chosen plan, instead of a filled pill that reads as pressable
- filled accent and danger buttons take accent-ink; the dark accent is a
  light blue and danger a coral, and white on either was unreadable
- login's packet pulses shift from green to the accent, so the sign-in
  screen is the same two blues as the marketing hero

The last hex literals and stock-palette classes are gone; the only ones left
are NetworkBackground's canvas fills, which cannot read a CSS variable and
are commented with the token each came from.

Only the web image rebuilds from this.
2026-07-26 18:33:07 +01:00
mrhid6andClaude Opus 5 11ebd324ad ci: only rebuild images whose inputs changed
Server Deploy / deploy (push) Successful in 2m38s
Every push rebuilt all six images regardless of what it touched. A git diff
against github.event.before now gates each build step.

Two things this needs to work at all: fetch-depth 0, because the default
shallow clone has a single commit and nothing to diff against, and git
installed in the dind container, which had node and npm but not git.

The path mapping follows the build contexts rather than intuition — the Go
images use a root context and COPY shared/, so shared/ fans out to all
three, while the Next images use their own directory and cannot be affected
from outside it. Anything that leaves no trustworthy base commit — manual
run, new branch, force-push whose old head is gone — lists every tracked
file instead, so the fallback is one code path rather than two.

Known gap, documented: a repo variable change pushes no commit, so nothing
rebuilds. workflow_dispatch is the escape hatch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 18:15:26 +01:00
mrhid6andClaude Opus 5 3a6c5ebae8 fix(admin): licence and plan features serialise as [], never null
Server Deploy / deploy (push) Successful in 5m22s
Free has no features, so a nil Go slice served "features": null. The
portal's type said string[], called .length on it, and took the page down.

The guarantee lives on a named Features type with its own MarshalJSON
rather than at each of the six places a licence or plan is serialised,
because the seventh is the one that would have been forgotten. Applying it
at marshal time also fixes rows already holding null in Mongo, which a
write-side fix alone would not.

Same class, found while checking the rest: staffListInstances built its
expiring filter from a nil []string, marshalling to $in: null, which Mongo
rejects — so the quiet week when nothing is expiring is precisely when the
staff Operations page's own query would have failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 18:10:07 +01:00
mrhid6andClaude Opus 5 34a0373eca feat(adminsite): one masthead, a shared page frame, collapsible instance records
Server Deploy / deploy (push) Successful in 53s
The console had components but no shell: a brand bar and a nav strip stacked
into 100px carrying eight words, no sign-out, no account identity, and an
Overview link hardcoded to text-accent so it read as the current page on
every screen. Nine pages each hand-rolled their own header.

AppBar replaces both bars and derives its active state from usePathname.
Settings moves into AccountMenu — it is your password, not a destination —
taking appearance with it, which finally sets the data-theme attribute the
token blocks have supported in both directions since they were written.
That leaves three customer destinations: Overview, People, Billing.

PageFrame adds a support rail so a page has a floor, and InstanceRecord
replaces InstanceCard with one component that opens and closes: an account
with a single instance used to render a third of a row of summary with its
substance a click away. It defaults open when the instance is the only one
or needs attention.

No plan card in the rail: tier, limits and expiry belong to a licence and a
licence belongs to one instance, so an account holding a Free cloud instance
and a Professional self-hosted one has no single plan. The rail carries only
what is account-wide.

Tokens and globals.css are untouched — they stay verbatim shared with site/.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 17:45:53 +01:00
mrhid6andClaude Opus 5 6bf288f83e docs: phase 3, grants project rather than federate
Records why instance_members is an index and not the authority, why hqsync
is not part of inject, and why an invitation carries no password.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 16:43:52 +01:00
514 changed files with 85923 additions and 28901 deletions
+1
View File
@@ -0,0 +1 @@
graphify-out/graph.json merge=graphify
+221
View File
@@ -0,0 +1,221 @@
name: Chart Release
on:
# Every push that touches the chart is validated. Publishing is separate and
# deliberate: a chart version is immutable in the registry once pushed, so
# it must come from a tag someone chose, not from whatever landed on main.
# No `paths` filter on push, deliberately. A paths filter applies to tag
# pushes too, so tagging a commit that happened not to touch the chart
# would skip the publish entirely — a release that silently does nothing.
# Validation is seconds of helm rendering; running it on every push to main
# is cheaper than that failure mode.
push:
branches:
- main
tags:
- "chart/v*"
pull_request:
paths:
- "deploy/chart/**"
workflow_dispatch:
env:
CHART_DIR: deploy/chart/vantage
HELM_VERSION: v3.16.3
jobs:
chart:
runs-on: ubuntu-docker
container: alpine:3.21
steps:
# git for actions/checkout, curl for both the Helm download and the
# registry upload, tar because the Helm tarball is not self-extracting.
- name: Setup
run: apk add --no-cache bash curl git tar nodejs npm
- name: Install Helm
run: |
set -eu
curl -fsSL "https://get.helm.sh/helm-${HELM_VERSION}-linux-amd64.tar.gz" \
| tar -xz -C /tmp linux-amd64/helm
mv /tmp/linux-amd64/helm /usr/local/bin/helm
helm version --short
- name: Checkout
uses: actions/checkout@v4
- name: Lint
run: helm lint "$CHART_DIR"
# Rendering is the real test. `helm lint` accepts a chart whose
# templates fail to execute, and every guard in this chart is a
# template `fail` that only fires during rendering.
- name: Render default values
run: helm template test "$CHART_DIR" > /dev/null
- name: Render a multi-replica install
run: |
helm template test "$CHART_DIR" \
--set server.replicaCount=3 \
--set web.replicaCount=3 > /dev/null
# The reaper deletes whole instances, so "does this env appear only
# in cloud mode" is worth asserting rather than eyeballing.
- name: Check the reaper is cloud-only
run: |
set -eu
if helm template test "$CHART_DIR" | grep -q FREE_INSTANCE_REAP_AFTER; then
echo "FREE_INSTANCE_REAP_AFTER is set on a self-hosted render"
exit 1
fi
if ! helm template test "$CHART_DIR" \
--set server.env.deploymentType=cloud \
| grep -q FREE_INSTANCE_REAP_AFTER; then
echo "FREE_INSTANCE_REAP_AFTER is missing from a cloud render"
exit 1
fi
echo "ok: reaper configured in cloud mode only"
- name: Render against external Redis and MongoDB
run: |
helm template test "$CHART_DIR" \
--set redis.enabled=false \
--set redis.addr=redis.example.com:6379 \
--set mongo.enabled=false \
--set server.env.mongoUri=mongodb://mongo.example.com:27017/vantage > /dev/null
- name: Render with the Traefik ingress
run: |
helm template test "$CHART_DIR" \
--set ingress.enabled=true \
--set ingress.web.host=vantage.example.com \
--set ingress.grpc.host=agents.example.com \
--set ingress.tls.certResolver=letsencrypt \
--set server.env.grpcHost=agents.example.com:443 > /dev/null
# The shape the cloud deployment actually uses: a wildcard tenant
# namespace, /api and /auth routed at the edge, and no apex — that
# belongs to the marketing site, which this chart does not deploy.
- name: Render a wildcard host with edge-routed API paths
run: |
helm template test "$CHART_DIR" \
--set ingress.enabled=true \
--set 'ingress.web.host=*.vantage.example.com' \
--set ingress.api.enabled=true \
--set ingress.grpc.host=agents.example.com \
--set server.env.grpcHost=agents.example.com:443 \
--set ingress.tls.secretName=vantage-tls \
--set ingress.tls.grpcSecretName=agents-tls > /dev/null
# The guards are load-bearing, so their absence is a regression the
# same way a broken render is. Each of these must fail.
- name: Check the guards still refuse bad values
run: |
set -eu
refuses() {
desc="$1"; shift
if helm template test "$CHART_DIR" "$@" > /dev/null 2>&1; then
echo "GUARD MISSING: $desc was accepted"
exit 1
fi
echo "ok: refused $desc"
}
refuses "mongo disabled with an in-chart URI" \
--set mongo.enabled=false
refuses "redis disabled with no external address" \
--set redis.enabled=false
refuses "multiple replicas on a ReadWriteOnce volume" \
--set server.replicaCount=2 --set server.persistence.enabled=true
refuses "ingress with no web host" \
--set ingress.enabled=true
refuses "edge-routed API with an empty path list" \
--set ingress.enabled=true \
--set ingress.web.host=vantage.example.com \
--set ingress.grpc.enabled=false \
--set ingress.api.enabled=true \
--set 'ingress.api.paths=null'
refuses "gRPC ingress with no host" \
--set ingress.enabled=true \
--set ingress.web.host=vantage.example.com \
--set server.env.grpcHost=agents.example.com:443
refuses "gRPC ingress while grpcHost is still in-cluster" \
--set ingress.enabled=true \
--set ingress.web.host=vantage.example.com \
--set ingress.grpc.host=agents.example.com
- name: Read the chart version
id: chart
run: |
set -eu
VERSION="$(grep '^version:' "$CHART_DIR/Chart.yaml" | awk '{print $2}')"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "chart version is $VERSION"
# Chart.yaml is the source of truth for the version; the tag only
# says "publish this one". A mismatch is a mistake worth stopping
# for — the alternative is stamping the tag over Chart.yaml, which
# leaves the repository disagreeing with what was published.
- name: Check the tag matches Chart.yaml
if: startsWith(github.ref, 'refs/tags/chart/v')
run: |
set -eu
TAG_VERSION="${GITHUB_REF_NAME#chart/v}"
CHART_VERSION="${{ steps.chart.outputs.version }}"
if [ "$TAG_VERSION" != "$CHART_VERSION" ]; then
echo "tag chart/v$TAG_VERSION does not match Chart.yaml version $CHART_VERSION"
echo "bump version: in $CHART_DIR/Chart.yaml, or retag."
exit 1
fi
- name: Package
run: |
set -eu
mkdir -p dist
helm package "$CHART_DIR" --destination dist
ls -l dist
- name: Publish to the Gitea chart registry
if: startsWith(github.ref, 'refs/tags/chart/v')
env:
# github.server_url is this Gitea instance, so the registry
# host needs no variable of its own and cannot drift from it.
REGISTRY: ${{ github.server_url }}/api/packages/${{ github.repository_owner }}/helm/api/charts
# The same pair server-deploy.yml uses for `docker login`.
# RELEASE_TOKEN, not REGISTRY_PASSWORD: the latter is named in
# the docs but set by no workflow, and an unset secret becomes
# an empty password, which Gitea reports as "Failed to
# authenticate user" rather than as a missing credential.
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_TOKEN: ${{ secrets.RELEASE_TOKEN }}
CHART_VERSION: ${{ steps.chart.outputs.version }}
run: |
set -eu
PKG="dist/vantage-${CHART_VERSION}.tgz"
test -f "$PKG"
# Checked explicitly, because the failure it prevents is a
# 401 that looks like a permissions problem on the token that
# was never sent.
if [ -z "${REGISTRY_USER}" ] || [ -z "${REGISTRY_TOKEN}" ]; then
echo "REGISTRY_USER or RELEASE_TOKEN is not set on this repository."
echo "RELEASE_TOKEN needs the write:package scope to publish a chart."
exit 1
fi
echo "publishing to ${REGISTRY} as ${REGISTRY_USER}"
# --fail-with-body so an HTTP error is a failed step with the
# server's explanation, rather than a green run that published
# nothing. A repeated version is rejected by the registry;
# that is the intended behaviour, not something to retry past.
curl --fail-with-body -sS \
--user "${REGISTRY_USER}:${REGISTRY_TOKEN}" \
-X POST \
--upload-file "$PKG" \
"$REGISTRY"
echo "published vantage ${CHART_VERSION}"
echo " helm repo add vantage ${{ github.server_url }}/api/packages/${{ github.repository_owner }}/helm"
echo " helm install vantage vantage/vantage --version ${CHART_VERSION}"
+114 -3
View File
@@ -4,16 +4,90 @@ on:
push:
branches:
- main
# Manual runs rebuild everything: there is no "before" commit to diff
# against, which the change detection below treats as "build it all". That
# is also the escape hatch for a repo VARIABLE change — editing API_URL or
# ADMIN_ENV pushes no commit, so nothing would rebuild on its own.
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-docker
container: docker:dind
steps:
- name: Setup Node
run: apk add --update nodejs npm
# git is needed twice over: actions/checkout clones with it, and the
# change detection below diffs with it.
- name: Setup
run: apk add --update nodejs npm git
- name: Checkout
uses: actions/checkout@v4
with:
# The default shallow clone has one commit, which cannot be
# diffed against the previous push.
fetch-depth: 0
- name: Work out what changed
id: changed
run: |
set -eu
BEFORE="${{ github.event.before }}"
ZERO="0000000000000000000000000000000000000000"
# Build everything whenever the comparison cannot be trusted:
# a manual run, a brand-new branch, or a force-push whose old
# head is no longer in the repository. Listing every tracked
# file makes every filter below match, so there is no second
# code path to keep correct.
if [ -z "$BEFORE" ] || [ "$BEFORE" = "$ZERO" ] || ! git cat-file -e "${BEFORE}^{commit}" 2>/dev/null; then
echo "No usable base commit — building every image."
git ls-files > /tmp/changed.txt
else
git diff --name-only "$BEFORE" HEAD > /tmp/changed.txt
fi
echo "--- changed files ---"
cat /tmp/changed.txt
echo "---------------------"
# A change to the workflow itself can change a build arg, and
# a build arg is baked into the image, so it rebuilds all.
if grep -qE '^\.gitea/workflows/' /tmp/changed.txt; then
ALL=1
else
ALL=0
fi
flag() {
name="$1"
pattern="$2"
if [ "$ALL" = "1" ] || grep -qE "$pattern" /tmp/changed.txt; then
echo "$name=true" >> "$GITHUB_OUTPUT"
echo "build $name"
else
echo "$name=false" >> "$GITHUB_OUTPUT"
echo "skip $name"
fi
}
# The three Go images build from the repo root and COPY
# shared/ plus their own directory, so shared/ rebuilds all
# three. proto/ is in server's list as insurance: the
# generated pb is committed under server/, but a proto change
# that someone regenerates in the same push should not depend
# on that ordering.
flag server '^(server/|shared/|proto/|default_steps/|go\.work)'
flag sitesvc '^(sitesvc/|shared/|go\.work)'
flag admin '^(admin/|shared/|go\.work)'
# The three Next images and the docs site use their own
# directory as the build context, so nothing outside it can
# affect them.
flag web '^web/'
flag site '^site/'
flag adminsite '^adminsite/'
flag docsite '^docsite/'
- name: Log in to registry
run: |
@@ -21,7 +95,26 @@ jobs:
docker login ${{ vars.DOCKER_HOST }} \
-u "${{ secrets.REGISTRY_USER }}" --password-stdin
- name: Set up Go
if: steps.changed.outputs.server == 'true'
uses: actions/setup-go@v5
with:
go-version: "1.26"
cache: true
cache-dependency-path: server/go.sum
- name: Verify the OpenAPI document is current
if: steps.changed.outputs.server == 'true'
run: |
go install github.com/swaggo/swag/v2/cmd/swag@v2.0.0-rc5
cd server
swag init --generalInfo cmd/main.go --dir ./,../shared \
--output internal/api/docs --outputTypes json --v3.1
mv -f internal/api/docs/swagger.json internal/api/docs/openapi.json
git diff --exit-code internal/api/docs/openapi.json
- name: Build and push server image
if: steps.changed.outputs.server == 'true'
run: |
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/server:latest"
# Root context: server depends on the shared module.
@@ -29,16 +122,17 @@ jobs:
docker push "$IMAGE"
- name: Build and push web image
if: steps.changed.outputs.web == 'true'
run: |
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/web:latest"
docker build \
--build-arg NEXT_PUBLIC_API_URL="${{ vars.API_URL }}" \
--build-arg NEXT_PUBLIC_HQ_URL="${{ vars.HQ_URL }}" \
-t "$IMAGE" \
-f web/Dockerfile web/
docker push "$IMAGE"
- name: Build and push site image
if: steps.changed.outputs.site == 'true'
run: |
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/site:latest"
docker build \
@@ -50,6 +144,7 @@ jobs:
docker push "$IMAGE"
- name: Build and push sitesvc image
if: steps.changed.outputs.sitesvc == 'true'
run: |
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/sitesvc:latest"
# Root context: sitesvc depends on the shared module.
@@ -57,6 +152,7 @@ jobs:
docker push "$IMAGE"
- name: Build and push admin image
if: steps.changed.outputs.admin == 'true'
run: |
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/admin:latest"
# Root context: admin depends on the shared module.
@@ -64,11 +160,26 @@ jobs:
docker push "$IMAGE"
- name: Build and push adminsite image
if: steps.changed.outputs.adminsite == 'true'
run: |
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/adminsite:latest"
docker build \
--build-arg NEXT_PUBLIC_ADMIN_API_URL="${{ vars.ADMIN_API_URL }}" \
--build-arg NEXT_PUBLIC_ADMIN_ENV="${{ vars.ADMIN_ENV }}" \
--build-arg NEXT_PUBLIC_PADDLE_CLIENT_TOKEN="${{ vars.PADDLE_CLIENT_TOKEN }}" \
--build-arg NEXT_PUBLIC_PADDLE_ENV="${{ vars.PADDLE_ENV }}" \
--build-arg NEXT_PUBLIC_SITE_URL="${{ vars.SITE_URL }}" \
-t "$IMAGE" \
-f adminsite/Dockerfile adminsite/
docker push "$IMAGE"
- name: Build and push docsite image
if: steps.changed.outputs.docsite == 'true'
run: |
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/docsite:latest"
# DOCS_BASE_URL must match the proxy location that routes to
# this container and the directory the image serves from.
docker build \
-t "$IMAGE" \
-f docsite/Dockerfile docsite/
docker push "$IMAGE"
+6 -1
View File
@@ -2,6 +2,8 @@ node_modules
dist
build
.env
.env.bck
.env.live
docs/*
!docs/superpowers/
.superpowers
@@ -10,4 +12,7 @@ installer/*.msi
installer/nssm.zip
installer/checksums-msi.txt
.next
*.tsbuildinfo
*.tsbuildinfo
graphify-out
docker-compose.live.yml
.claude
+1159
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -15,11 +15,11 @@ import (
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/config"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"github.com/google/uuid"
"github.com/joho/godotenv"
"github.com/mrhid6/vantage/admin/internal/config"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/models"
"golang.org/x/crypto/bcrypt"
)
+30 -11
View File
@@ -10,17 +10,20 @@ import (
"syscall"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/api"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/billing"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/config"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/hqsync"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/inject"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/licensing"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/lifecycle"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/mail"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/paddle"
sharedmail "gitea.hostxtra.co.uk/mrhid6/vantage/shared/mail"
"github.com/joho/godotenv"
"github.com/mrhid6/vantage/admin/internal/api"
"github.com/mrhid6/vantage/admin/internal/auth"
"github.com/mrhid6/vantage/admin/internal/config"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/hqsync"
"github.com/mrhid6/vantage/admin/internal/inject"
"github.com/mrhid6/vantage/admin/internal/licensing"
"github.com/mrhid6/vantage/admin/internal/lifecycle"
"github.com/mrhid6/vantage/admin/internal/mail"
"github.com/mrhid6/vantage/admin/internal/models"
)
func main() {
@@ -34,7 +37,11 @@ func main() {
licensing.SetSigningKey(cfg.SigningKey)
api.SetAppLoginURL(cfg.AppLoginURL)
mail.Init(mail.Config{
if _, err := paddle.Init(cfg.PaddleAPIKey, cfg.PaddleEnv); err != nil {
log.Fatalf("paddle init: %v", err)
}
mail.Init(sharedmail.Sender{
Host: cfg.SMTPHost, Port: cfg.SMTPPort, From: cfg.SMTPFrom,
Username: cfg.SMTPUsername, Password: cfg.SMTPPassword,
PublicURL: cfg.PublicURL,
@@ -64,10 +71,21 @@ func main() {
idxCancel()
log.Fatalf("indexes: %v", err)
}
// Legacy plans are re-keyed BEFORE the seed, so the seed's fresh
// (self_hosted, professional) row cannot collide with the legacy self_hosted
// row's rename on deployment_tier_unique.
if err := models.MigrateLegacyPlans(idxCtx); err != nil {
idxCancel()
log.Fatalf("migrate legacy plans: %v", err)
}
if err := models.SeedPlans(idxCtx); err != nil {
idxCancel()
log.Fatalf("plan seed: %v", err)
}
if err := models.SeedCatalogue(idxCtx); err != nil {
idxCancel()
log.Fatalf("seed catalogue: %v", err)
}
if err := models.Backfill(idxCtx); err != nil {
idxCancel()
log.Fatalf("backfill: %v", err)
@@ -77,6 +95,7 @@ func main() {
reconcileCtx, stopReconcile := context.WithCancel(context.Background())
defer stopReconcile()
inject.StartReconciler(reconcileCtx)
billing.StartPlaceholderReconciler(reconcileCtx)
hqsync.Start(reconcileCtx)
lifecycle.SetPortalURL(cfg.PublicURL)
+3 -3
View File
@@ -1,4 +1,4 @@
module github.com/mrhid6/vantage/admin
module gitea.hostxtra.co.uk/mrhid6/vantage/admin
go 1.26
@@ -6,7 +6,7 @@ require (
github.com/gin-gonic/gin v1.10.0
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
github.com/mrhid6/vantage/shared v0.0.0-00010101000000-000000000000
gitea.hostxtra.co.uk/mrhid6/vantage/shared v0.0.0-00010101000000-000000000000
github.com/redis/go-redis/v9 v9.20.1
go.mongodb.org/mongo-driver/v2 v2.8.0
golang.org/x/crypto v0.54.0
@@ -52,4 +52,4 @@ require (
gopkg.in/yaml.v3 v3.0.1 // indirect
)
replace github.com/mrhid6/vantage/shared => ../shared
replace gitea.hostxtra.co.uk/mrhid6/vantage/shared => ../shared
+264
View File
@@ -0,0 +1,264 @@
package api
import (
"errors"
"fmt"
"net/http"
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/catalogue"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/licensing"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/paddle"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
// checkoutOptions serves everything the browser configurator needs to price a
// plan: the active plans (base allowances), the full catalogue (component prices
// in the running environment), and the environment name so the client can refuse
// a mismatch. The client token itself is baked into the adminsite build, never
// served from here.
func checkoutOptions(c *gin.Context) {
ctx := c.Request.Context()
plans := []models.Plan{}
if cur, err := db.Admin("plans").Find(ctx, bson.M{"active": true}); err == nil {
_ = cur.All(ctx, &plans)
}
rows, err := models.AllCatalogue(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"plans": plans,
"catalogue": rows,
"env": paddle.Get().Env(),
})
}
// createSelfHostedCheckout prepares a paid self-hosted checkout against the
// customer's REAL install UUID, and hands that id back for the checkout's
// custom_data.
//
// A licence binds to the install's UUID, so the buyer must have a control plane
// standing before they pay — the same precondition self-hosted Free already has.
// That is what removes the placeholder: there is no temporary identity to
// rewrite afterwards, the subscription's custom_data names the real instance
// from the first event, and the webhook issues with no claim step.
//
// An id this account already owns is REUSED rather than refused: upgrading a
// Free self-hosted install to a paid plan is the same purchase form, and
// refusing it would mean the only route to Professional was to unlink first.
// A UUID belonging to anyone else is still 409, from the unique index.
func createSelfHostedCheckout(c *gin.Context) {
s := auth.Current(c)
var body struct {
InstanceID string `json:"instance_id"`
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.InstanceID) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "instance_id is required"})
return
}
instanceID := strings.TrimSpace(body.InstanceID)
name := strings.TrimSpace(body.Name)
ctx := c.Request.Context()
var existing models.Instance
err := db.Admin("admin_instances").FindOne(ctx,
bson.M{"instance_id": instanceID, "account_id": s.AccountID}).Decode(&existing)
switch {
case err == nil:
if existing.Deployment != license.DeploymentSelfHosted {
c.JSON(http.StatusBadRequest, gin.H{
"error": "that instance is a cloud instance; change its plan from its own page"})
return
}
c.JSON(http.StatusOK, gin.H{"instance_id": existing.InstanceID})
return
case !errors.Is(err, mongo.ErrNoDocuments):
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "a name is required"})
return
}
inst, err := licensing.LinkInstance(ctx, s.AccountID, instanceID, name)
if err != nil {
status := http.StatusBadRequest
if errors.Is(err, licensing.ErrAlreadyLinked) {
status = http.StatusConflict
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
audit.Write(ctx, models.AuditEntry{
Actor: s.Email, Action: "instance.checkout_started", AccountID: s.AccountID,
Target: inst.InstanceID, Detail: "self-hosted", IP: c.ClientIP()})
c.JSON(http.StatusCreated, gin.H{"instance_id": inst.InstanceID})
}
// createCloudCheckout creates a PAID cloud placeholder and hands back its id so
// the browser can open a Paddle checkout keyed to it. Nothing is provisioned yet:
// a cloud instance costs real infrastructure, so it is created only once payment
// is confirmed, by the subscription webhook (billing.handleSubscription).
//
// This mirrors the self-hosted placeholder, with one difference that matters:
// admin owns the cloud UUID, so the id generated here is the id the instance
// will keep. Provisioning on the webhook reuses it (provision.CreateInstanceWithID),
// which is why there is no claim-and-rewrite step and the subscription's
// custom_data never goes stale. PendingOwnerUserID remembers who bought it so the
// webhook can make them the instance owner.
//
// An abandoned checkout therefore leaves only this row — no infrastructure — the
// same cheap, reap-safe state a self-hosted placeholder leaves.
func createCloudCheckout(c *gin.Context) {
s := auth.Current(c)
var body struct {
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.Name) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "a name is required"})
return
}
ctx := c.Request.Context()
inst := models.Instance{
InstanceID: uuid.NewString(),
AccountID: s.AccountID,
Name: strings.TrimSpace(body.Name),
Deployment: license.DeploymentCloud,
Status: models.StatusAwaitingLink,
Placeholder: true,
PendingOwnerUserID: s.UserID,
CreatedAt: time.Now().UTC(),
}
if _, err := db.Admin("admin_instances").InsertOne(ctx, inst); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
audit.Write(ctx, models.AuditEntry{
Actor: s.Email, Action: "instance.cloud_placeholder_created", AccountID: s.AccountID,
Target: inst.InstanceID, IP: c.ClientIP()})
c.JSON(http.StatusCreated, gin.H{"instance_id": inst.InstanceID})
}
// updateEntitlement sets an instance's DESIRED configuration and pushes the
// resulting line items to Paddle. It does NOT issue — the resulting
// subscription.updated webhook does, from granted. An increase is prorated
// immediately by Paddle; a reduction is recorded as desired and takes effect at
// renewal, so this never shrinks a live licence.
func updateEntitlement(c *gin.Context) {
inst, ok := ownedInstance(c, c.Param("id"))
if !ok {
return
}
ctx := c.Request.Context()
var body struct {
Tier string `json:"tier"`
Term string `json:"term"`
Servers int `json:"servers"`
Features []string `json:"features"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid configuration"})
return
}
plan, err := models.GetPlan(ctx, inst.Deployment, body.Tier)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "no such plan"})
return
}
if body.Servers < plan.BaseLimits.MaxServers && plan.BaseLimits.MaxServers != license.Unlimited {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("%s includes %d servers", plan.Name, plan.BaseLimits.MaxServers)})
return
}
desired := models.Config{Servers: body.Servers, Features: models.Features(body.Features).OrEmpty()}
items, err := catalogue.LineItems(ctx, paddle.Get().Env(), body.Term, plan, desired)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// A live subscription is required to update. None means this instance has
// never been paid for — that is a checkout, not an update.
var sub models.Subscription
if err := db.Admin("subscriptions").FindOne(ctx,
bson.M{"instance_id": inst.InstanceID, "status": models.SubActive}).Decode(&sub); err != nil {
c.JSON(http.StatusConflict, gin.H{"error": "no active subscription; start a checkout instead"})
return
}
pItems := make([]paddle.LineItem, 0, len(items))
for _, it := range items {
pItems = append(pItems, paddle.LineItem{PriceID: it.PriceID, Quantity: it.Quantity})
}
if err := paddle.Get().UpdateSubscriptionItems(ctx, sub.PaddleSubscriptionID, pItems); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "billing update failed; nothing changed"})
return
}
// Record desired now; the webhook Paddle sends back promotes to granted and
// reissues. Recording here makes the portal reflect the intent instantly
// rather than waiting on the round-trip.
limits, _, _ := catalogue.Resolve(ctx, plan, desired)
next := models.Entitlement{
InstanceID: inst.InstanceID, AccountID: inst.AccountID,
Deployment: inst.Deployment, Tier: body.Tier, Term: body.Term,
Desired: desired, ResolvedLimits: limits,
}
ent, _ := models.GetEntitlement(ctx, inst.InstanceID)
if ent != nil {
next.Granted = ent.Granted
next.GrantedAt = ent.GrantedAt
if desired.Servers < ent.Granted.Servers {
now := time.Now().UTC()
next.ScheduledChangeAt = &now
}
} else {
next.Granted = desired
}
if err := models.UpsertEntitlement(ctx, next); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
audit.Write(ctx, models.AuditEntry{
Actor: auth.Current(c).Email, Action: "entitlement.requested",
AccountID: inst.AccountID, Target: inst.InstanceID})
c.JSON(http.StatusOK, gin.H{"entitlement": next, "pending": next.Pending()})
}
// billingPortal mints a Paddle customer-portal URL. The account must already
// have a paddle_customer_id, which it learns from its first subscription webhook.
func billingPortal(c *gin.Context) {
s := auth.Current(c)
ctx := c.Request.Context()
var acc models.Account
if err := db.Admin("accounts").FindOne(ctx,
bson.M{"account_id": s.AccountID}).Decode(&acc); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "no account"})
return
}
if acc.PaddleCustomerID == "" {
c.JSON(http.StatusConflict, gin.H{"error": "no billing account yet; buy a paid plan first"})
return
}
url, err := paddle.Get().PortalSession(ctx, acc.PaddleCustomerID)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "could not open billing portal"})
return
}
c.JSON(http.StatusOK, gin.H{"url": url})
}
+388 -83
View File
@@ -1,6 +1,7 @@
package api
import (
"context"
"errors"
"fmt"
"log"
@@ -9,20 +10,21 @@ import (
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/cloudprov"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/inject"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/licensing"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/mail"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/provision"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/mrhid6/vantage/admin/internal/audit"
"github.com/mrhid6/vantage/admin/internal/auth"
"github.com/mrhid6/vantage/admin/internal/cloudprov"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/inject"
"github.com/mrhid6/vantage/admin/internal/licensing"
"github.com/mrhid6/vantage/admin/internal/mail"
"github.com/mrhid6/vantage/admin/internal/models"
"github.com/mrhid6/vantage/shared/license"
sharedmodels "github.com/mrhid6/vantage/shared/models"
"github.com/mrhid6/vantage/shared/provision"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
// ownedInstance resolves an instance and confirms the session's account owns it.
@@ -200,6 +202,96 @@ func listSubscriptions(c *gin.Context) {
c.JSON(http.StatusOK, subs)
}
// provisionCloudInstance provisions a real cloud instance in the control plane
// and records admin's row for it, WITHOUT issuing a licence. Both the Free
// create path and the paid-checkout path share it, so the provisioning — and its
// unwind-in-reverse rollback — has one definition rather than two that drift.
//
// It leaves the instance unlicensed on purpose: createInstance then issues Free,
// and createCloudCheckout leaves it for the paid subscription webhook to license.
// The returned rec carries no Tier or CurrentLicense; the caller sets those once
// it has issued.
//
// Errors are returned unwrapped for the provisioning step so the caller can still
// match provision.ErrEmailTaken / ErrNameRejected; later steps are wrapped.
func provisionCloudInstance(c *gin.Context, name string) (*models.Instance, error) {
ctx := c.Request.Context()
s := auth.Current(c)
var cu models.CustomerUser
if err := db.Admin("customer_users").FindOne(ctx,
bson.M{"user_id": s.UserID}).Decode(&cu); err != nil {
return nil, fmt.Errorf("read account: %w", err)
}
inst, err := cloudprov.CreateInstance(ctx, name, cu.Email, cu.PasswordHash, cu.UserID)
if err != nil {
return nil, err
}
rec := models.Instance{
InstanceID: inst.InstanceID,
AccountID: s.AccountID,
Name: inst.Name,
Slug: inst.Slug,
Deployment: license.DeploymentCloud,
Status: models.StatusActive,
CreatedAt: time.Now().UTC(),
}
if _, err := db.Admin("admin_instances").InsertOne(ctx, rec); err != nil {
// Unwind in reverse: the owner first, because RollbackInstance refuses
// an instance that still has users.
if uid, e := cloudprov.OwnerUserID(ctx, inst.InstanceID); e == nil {
_ = cloudprov.DeleteUser(ctx, inst.InstanceID, uid)
}
if e := cloudprov.RollbackInstance(ctx, inst.InstanceID); e != nil {
log.Printf("provisionCloudInstance: rollback of %s failed: %v", inst.InstanceID, e)
}
return nil, fmt.Errorf("record instance: %w", err)
}
// Record the owner's membership. Best-effort: the projected user already
// exists and is what actually grants access, so a missing row here costs a
// line in the members panel, not access — and the boot backfill rebuilds it.
ownerID, err := cloudprov.OwnerUserID(ctx, inst.InstanceID)
if err != nil {
log.Printf("provisionCloudInstance: owner lookup for %s: %v", inst.InstanceID, err)
} else if _, err := db.Admin("instance_members").InsertOne(ctx, models.InstanceMember{
MemberID: uuid.NewString(),
AccountID: s.AccountID,
InstanceID: inst.InstanceID,
CustomerUserID: cu.UserID,
ControlUserID: ownerID,
Role: sharedmodels.RoleOwner,
Email: cu.Email,
CreatedAt: time.Now().UTC(),
}); err != nil {
log.Printf("provisionCloudInstance: record owner membership for %s: %v", inst.InstanceID, err)
}
audit.Write(ctx, models.AuditEntry{
Actor: s.Email, Action: "instance.created", AccountID: s.AccountID,
Target: inst.InstanceID, Detail: "slug=" + inst.Slug, IP: c.ClientIP()})
return &rec, nil
}
// cloudProvisionError maps the errors provisionCloudInstance can surface onto the
// customer-facing responses shared by the Free and paid-checkout paths.
func cloudProvisionError(c *gin.Context, err error) {
switch {
case errors.Is(err, provision.ErrEmailTaken):
// users.email is unique per instance, so this means the address already
// owns a user in an instance we are not creating — a legacy cloud tenant.
// Staff have to attach that one by hand.
c.JSON(http.StatusConflict, gin.H{
"error": "that email address already belongs to an existing Vantage instance; contact support@hostxtra.co.uk and we will link it to your account"})
case errors.Is(err, provision.ErrNameRejected):
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not create the instance"})
}
}
// createInstance provisions a Free cloud instance for the calling account.
//
// The ordering matters and each step unwinds the previous one. Licence issuance
@@ -221,8 +313,13 @@ func createInstance(c *gin.Context) {
// Pre-check the Free rule so we never create an instance we then cannot
// licence. licensing.Issue enforces it too; this is the friendly refusal.
//
// Scoped to cloud because that is what this endpoint creates. It MUST match
// checkFreeLimit's scoping — a pre-check stricter than the issuer refuses
// something that would have worked.
n, err := db.Admin("admin_instances").CountDocuments(ctx, bson.M{
"account_id": s.AccountID,
"deployment": license.DeploymentCloud,
"tier": license.TierFree,
"status": bson.M{"$ne": models.StatusCancelled},
})
@@ -232,79 +329,16 @@ func createInstance(c *gin.Context) {
}
if n > 0 {
c.JSON(http.StatusConflict, gin.H{
"error": "this account already has a Free instance"})
"error": "this account already has a Free cloud instance"})
return
}
var cu models.CustomerUser
if err := db.Admin("customer_users").FindOne(ctx,
bson.M{"user_id": s.UserID}).Decode(&cu); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not read your account"})
return
}
inst, err := cloudprov.CreateInstance(ctx, name, cu.Email, cu.PasswordHash, cu.UserID)
rec, err := provisionCloudInstance(c, name)
if err != nil {
if errors.Is(err, provision.ErrEmailTaken) {
// users.email is unique per instance, so this means the address
// already owns a user in an instance we are not creating — a legacy
// cloud tenant. Staff have to attach that one by hand.
c.JSON(http.StatusConflict, gin.H{
"error": "that email address already belongs to an existing Vantage instance; contact support@hostxtra.co.uk and we will link it to your account"})
return
}
if errors.Is(err, provision.ErrNameRejected) {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not create the instance"})
cloudProvisionError(c, err)
return
}
rec := models.Instance{
InstanceID: inst.InstanceID,
AccountID: s.AccountID,
Name: inst.Name,
Slug: inst.Slug,
Deployment: license.DeploymentCloud,
Status: models.StatusActive,
CreatedAt: time.Now().UTC(),
}
if _, err := db.Admin("admin_instances").InsertOne(ctx, rec); err != nil {
// Unwind in reverse: the owner first, because RollbackInstance refuses
// an instance that still has users.
if uid, e := cloudprov.OwnerUserID(ctx, inst.InstanceID); e == nil {
_ = cloudprov.DeleteUser(ctx, inst.InstanceID, uid)
}
if e := cloudprov.RollbackInstance(ctx, inst.InstanceID); e != nil {
log.Printf("createInstance: rollback of %s failed: %v", inst.InstanceID, e)
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not create the instance"})
return
}
// Record the owner's membership. Best-effort: the projected user already
// exists and is what actually grants access, so a missing row here costs a
// line in the members panel, not access — and the boot backfill rebuilds it.
ownerID, err := cloudprov.OwnerUserID(ctx, inst.InstanceID)
if err != nil {
log.Printf("createInstance: owner lookup for %s: %v", inst.InstanceID, err)
} else if _, err := db.Admin("instance_members").InsertOne(ctx, models.InstanceMember{
MemberID: uuid.NewString(),
AccountID: s.AccountID,
InstanceID: inst.InstanceID,
CustomerUserID: cu.UserID,
ControlUserID: ownerID,
Role: sharedmodels.RoleOwner,
Email: cu.Email,
CreatedAt: time.Now().UTC(),
}); err != nil {
log.Printf("createInstance: record owner membership for %s: %v", inst.InstanceID, err)
}
audit.Write(ctx, models.AuditEntry{
Actor: s.Email, Action: "instance.created", AccountID: s.AccountID,
Target: inst.InstanceID, Detail: "slug=" + inst.Slug, IP: c.ClientIP()})
inst := rec
// Past this point nothing fails the request.
lic, err := licensing.Issue(ctx, licensing.IssueInput{
@@ -322,7 +356,7 @@ func createInstance(c *gin.Context) {
inject.Deliver(ctx, lic)
if mail.Enabled() {
if err := mail.SendInstanceReady(s.Email, inst.Name,
if err := mail.Default.SendInstanceReady(s.Email, inst.Name,
loginURLFor(inst.Slug), lic.ExpiresAt); err != nil {
log.Printf("createInstance: instance-ready email to %s: %v", s.Email, err)
}
@@ -364,10 +398,16 @@ func renewInstance(c *gin.Context) {
return
}
// Free renews on its deployment's only term: monthly for cloud, annual for
// self-hosted. Reading it from TermsFor rather than hardcoding is what stops
// a self-hosted instance being handed a one-month licence.
terms := license.TermsFor(inst.Deployment)
term := terms[len(terms)-1]
lic, err := licensing.Issue(ctx, licensing.IssueInput{
InstanceID: inst.InstanceID,
Tier: license.TierFree,
Term: "monthly",
Term: term,
Reason: models.ReasonRenewal,
IssuedBy: "self-serve",
})
@@ -375,7 +415,9 @@ func renewInstance(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
inject.Deliver(ctx, lic)
// Cloud is injected; self-hosted is delivered to the customer, because their
// database is theirs and we cannot write to it.
deliver(c, inst, lic)
// Clear the notice log so the next term starts the sequence again. Issue has
// already set status back to active.
@@ -391,7 +433,7 @@ func renewInstance(c *gin.Context) {
Target: inst.InstanceID, IP: c.ClientIP()})
if mail.Enabled() {
if err := mail.SendRenewed(s.Email, inst.Name, lic.ExpiresAt); err != nil {
if err := mail.Default.SendRenewed(s.Email, inst.Name, lic.ExpiresAt); err != nil {
log.Printf("renewInstance: renewed email to %s: %v", s.Email, err)
}
}
@@ -414,6 +456,269 @@ var appLoginURL string
// SetAppLoginURL is called from main.
func SetAppLoginURL(v string) { appLoginURL = v }
// claimFree issues a Free licence on a linked self-hosted instance.
//
// The link step creates the row; this gives it a licence. They are separate
// because linking is about identity — proving which install is yours — and
// claiming is about entitlement, and a customer who links an install and then
// changes their mind should not have consumed their one Free allowance.
//
// Free is outside Paddle entirely, so there is no checkout, no subscription and
// nothing to reconcile. licensing.Issue's own checkFreeLimit is the real guard;
// the count here exists to refuse politely before anything is written.
func claimFree(c *gin.Context) {
inst, ok := ownedInstance(c, c.Param("id"))
if !ok {
return
}
ctx := c.Request.Context()
// Cloud Free is claimed at creation by POST /api/instances. Allowing it here
// too would be a second way to reach the same state, with its own bugs.
if inst.Deployment != license.DeploymentSelfHosted {
c.JSON(http.StatusBadRequest, gin.H{
"error": "cloud instances get their Free licence when they are created"})
return
}
if inst.CurrentLicense != "" {
c.JSON(http.StatusConflict, gin.H{
"error": "this instance already has a licence"})
return
}
plan, err := models.GetPlan(ctx, license.DeploymentSelfHosted, license.TierFree)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "no Free plan configured"})
return
}
if !plan.Active {
c.JSON(http.StatusForbidden, gin.H{
"error": "Free self-hosted is not currently offered"})
return
}
// The entitlement is written BEFORE the licence, so Issue snapshots it rather
// than falling back to the plan base. They are the same numbers today, but
// the ordering is what makes that a coincidence rather than a dependency.
if err := models.UpsertEntitlement(ctx, models.Entitlement{
InstanceID: inst.InstanceID,
AccountID: inst.AccountID,
Deployment: license.DeploymentSelfHosted,
Tier: license.TierFree,
Term: "annual",
Desired: models.Config{Servers: plan.BaseLimits.MaxServers, Features: models.Features{}},
Granted: models.Config{Servers: plan.BaseLimits.MaxServers, Features: models.Features{}},
ResolvedLimits: plan.BaseLimits,
}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
s := auth.Current(c)
lic, err := licensing.Issue(ctx, licensing.IssueInput{
InstanceID: inst.InstanceID,
Tier: license.TierFree,
// Annual, and not a choice. Self-hosted sells annual only because the
// term length is the revocation window for an offline licence.
Term: "annual",
Reason: models.ReasonNew,
IssuedBy: s.Email,
})
if err != nil {
status := http.StatusBadRequest
if errors.Is(err, licensing.ErrFreeLimit) {
status = http.StatusConflict
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
deliver(c, inst, lic)
audit.Write(ctx, models.AuditEntry{
Actor: s.Email, Action: "instance.claimed_free", AccountID: s.AccountID,
Target: inst.InstanceID, Detail: "self-hosted Free, annual", IP: c.ClientIP()})
c.JSON(http.StatusCreated, lic)
}
// renameInstance changes a cloud instance's name and moves it to the slug that
// name derives to.
//
// The control plane is written FIRST, because instances.slug carries the unique
// index and that index is what actually settles a race between two accounts
// reaching for the same name. Admin's own row follows; if that write fails the
// control plane is put back, because HQ printing a host that is not the host is
// worse than a failed rename.
//
// No licence is issued and Paddle is not called: a licence binds the instance
// UUID, and a rename does not change it.
func renameInstance(c *gin.Context) {
inst, ok := ownedInstance(c, c.Param("id"))
if !ok {
return
}
if inst.Deployment != license.DeploymentCloud {
c.JSON(http.StatusBadRequest, gin.H{"error": selfHostedRefusal})
return
}
if inst.Placeholder {
c.JSON(http.StatusConflict, gin.H{"error": "this instance is not provisioned yet"})
return
}
var body struct {
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
return
}
name := strings.TrimSpace(body.Name)
if name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
return
}
ctx := c.Request.Context()
// The unwind and the audit write run on a context detached from the request.
// The commonest reason the admin-side write fails at all is the caller
// walking away, and an unwind sharing that context fails with it — leaving
// the control plane renamed and admin's row not, which is the exact
// divergence this handler is arranged to prevent.
//
// Only the cancellation is detached here; each deadline is derived at its use
// site below. A deadline started before the forward work is a deadline the
// unwind may never get to use — a control plane slow enough to make the admin
// write fail is exactly the one that would have spent it already.
detached := context.WithoutCancel(ctx)
// Claim the cooldown atomically BEFORE the control-plane call. Checking it
// and then acting lets two parallel PUTs both pass the check and then
// interleave their two-database writes, which ends with the two databases
// disagreeing about the host — a worse outcome than either rename losing.
// The conditional update IS the cooldown; there is no second reading of it.
now := time.Now().UTC()
var claimed models.Instance
err := db.Admin("admin_instances").FindOneAndUpdate(ctx,
bson.M{
"instance_id": inst.InstanceID,
"account_id": inst.AccountID,
"$or": []bson.M{
{"renamed_at": bson.M{"$exists": false}},
{"renamed_at": bson.M{"$lte": now.Add(-models.RenameCooldown)}},
},
},
bson.M{"$set": bson.M{"renamed_at": now}}).Decode(&claimed)
if err != nil {
if !errors.Is(err, mongo.ErrNoDocuments) {
log.Printf("renameInstance: claiming the cooldown on %s: %v", inst.InstanceID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not rename the instance"})
return
}
// No match means the cooldown is live or the row has gone; only a
// re-read tells those apart, and they are different answers.
var cur models.Instance
if err := db.Admin("admin_instances").FindOne(ctx,
bson.M{"instance_id": inst.InstanceID, "account_id": inst.AccountID}).Decode(&cur); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if cur.RenamedAt != nil {
until := cur.RenamedAt.Add(models.RenameCooldown)
c.JSON(http.StatusTooManyRequests, gin.H{
"error": fmt.Sprintf("this instance was renamed recently; it can be renamed again after %s UTC", until.Format("2 Jan 2006 15:04")),
"retry_after": until,
})
return
}
// The row is here and its cooldown is spent, yet the claim matched
// nothing: it changed under us. Nothing has been written, so refuse
// rather than guess which way.
log.Printf("renameInstance: cooldown claim on %s matched nothing against an eligible row", inst.InstanceID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not rename the instance"})
return
}
// releaseClaim puts renamed_at back to whatever the claim overwrote — the
// previous instant, or absent when there was none. Every failure past the
// claim owes the customer their rename back.
releaseClaim := func(after string) {
undo := bson.M{"$unset": bson.M{"renamed_at": ""}}
if claimed.RenamedAt != nil {
undo = bson.M{"$set": bson.M{"renamed_at": *claimed.RenamedAt}}
}
rcCtx, cancel := context.WithTimeout(detached, 5*time.Second)
defer cancel()
if _, err := db.Admin("admin_instances").UpdateOne(rcCtx,
bson.M{"instance_id": inst.InstanceID}, undo); err != nil {
log.Printf("renameInstance: releasing the cooldown claim on %s after %s: %v", inst.InstanceID, after, err)
}
}
renamed, prevName, prevSlug, err := cloudprov.RenameInstance(ctx, inst.InstanceID, name)
switch {
case errors.Is(err, provision.ErrSlugTaken):
releaseClaim("a taken slug")
c.JSON(http.StatusConflict, gin.H{"error": "that name is already in use — try another"})
return
case errors.Is(err, provision.ErrNameRejected):
releaseClaim("a rejected name")
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
return
case err != nil:
releaseClaim("a failed control-plane rename")
log.Printf("renameInstance: control plane rename of %s: %v", inst.InstanceID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not rename the instance"})
return
}
// A matched count of zero is a silent version of the same failure: the
// control plane moved and admin's row did not.
res, err := db.Admin("admin_instances").UpdateOne(ctx,
bson.M{"instance_id": inst.InstanceID},
bson.M{"$set": bson.M{"name": renamed.Name, "slug": renamed.Slug}})
if err == nil && res.MatchedCount == 0 {
err = errors.New("admin_instances row matched nothing")
}
if err != nil {
// The control plane's own previous values, not admin's copy: admin's may
// be stale, and its slug is omitempty.
rbCtx, rbCancel := context.WithTimeout(detached, 5*time.Second)
if rbErr := cloudprov.RestoreInstanceIdentity(rbCtx, inst.InstanceID, prevName, prevSlug); rbErr != nil {
log.Printf("renameInstance: rollback of %s failed: %v", inst.InstanceID, rbErr)
}
rbCancel()
releaseClaim("a failed record write")
log.Printf("renameInstance: record rename of %s: %v", inst.InstanceID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not rename the instance"})
return
}
if renamed.Slug == prevSlug {
// The cooldown exists because a rename moves the DNS host; a cosmetic
// edit that derives to the same slug moves nothing, so it should not
// spend one. The claim is already written by this point — releasing it
// is how that is expressed now the check is atomic.
releaseClaim("a rename that did not move the host")
}
s := auth.Current(c)
auCtx, auCancel := context.WithTimeout(detached, 5*time.Second)
audit.Write(auCtx, models.AuditEntry{
Actor: s.Email, Action: "instance.renamed", AccountID: s.AccountID,
Target: inst.InstanceID, Detail: prevSlug + " -> " + renamed.Slug, IP: c.ClientIP()})
auCancel()
c.JSON(http.StatusOK, gin.H{
"instance_id": inst.InstanceID,
"name": renamed.Name,
"slug": renamed.Slug,
// The same builder the licence emails use, rather than a second opinion
// about how a tenant host is spelled. Empty when APP_LOGIN_URL is unset.
"login_url": loginURLFor(renamed.Slug),
})
}
// deliver sends a freshly issued licence where it needs to go. Cloud instances
// are injected; self-hosted customers are emailed and can download.
//
@@ -426,6 +731,6 @@ func deliver(c *gin.Context, inst *models.Instance, lic *models.License) {
}
s := auth.Current(c)
if s != nil && mail.Enabled() {
_ = mail.SendLicense(s.Email, inst.Name, lic.Blob)
_ = mail.Default.SendLicense(s.Email, inst.Name, lic.Blob)
}
}
+187
View File
@@ -0,0 +1,187 @@
package api
import (
"errors"
"fmt"
"net/http"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/catalogue"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"github.com/gin-gonic/gin"
"go.mongodb.org/mongo-driver/v2/bson"
)
// entitlementBody is what a caller may set.
//
// Only Desired is writable. Granted is what a payment confirmed, and letting a
// form set it would let the portal grant itself a licence — which is the one
// thing this whole split exists to prevent. Staff promote Granted explicitly
// through a separate flag, because staff issuing a licence to somebody who has
// not paid is a real operation with a real reason, and it should be one they
// took on purpose and left an audit row for.
type entitlementBody struct {
Tier string `json:"tier"`
Term string `json:"term"`
Servers int `json:"servers"`
Features []string `json:"features"`
// Grant promotes Desired into Granted in the same write. Staff only.
Grant bool `json:"grant"`
}
// getEntitlement serves the customer's own view of one instance's configuration.
func getEntitlement(c *gin.Context) {
inst, ok := ownedInstance(c, c.Param("id"))
if !ok {
return
}
ent, err := models.GetEntitlement(c.Request.Context(), inst.InstanceID)
if errors.Is(err, models.ErrNoEntitlement) {
c.JSON(http.StatusNotFound, gin.H{"error": "no entitlement"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"entitlement": ent, "pending": ent.Pending()})
}
func staffGetEntitlement(c *gin.Context) {
var inst models.Instance
if err := db.Admin("admin_instances").FindOne(c.Request.Context(),
bson.M{"instance_id": c.Param("id")}).Decode(&inst); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "no such instance"})
return
}
ent, err := models.GetEntitlement(c.Request.Context(), inst.InstanceID)
if errors.Is(err, models.ErrNoEntitlement) {
c.JSON(http.StatusNotFound, gin.H{"error": "no entitlement"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"entitlement": ent, "pending": ent.Pending()})
}
// staffSetEntitlement writes an instance's configuration.
//
// This is the endpoint that makes metering usable before Paddle exists: staff
// configure, then issue. It does NOT issue — recording what an instance is
// allowed and signing a licence for it stay separate, so a bad configuration is
// a row to correct rather than a licence to supersede.
func staffSetEntitlement(c *gin.Context) {
ctx := c.Request.Context()
var body entitlementBody
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid entitlement"})
return
}
var inst models.Instance
if err := db.Admin("admin_instances").FindOne(ctx,
bson.M{"instance_id": c.Param("id")}).Decode(&inst); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "no such instance"})
return
}
tier := body.Tier
if tier == "" {
tier = inst.Tier
}
plan, err := models.GetPlan(ctx, inst.Deployment, tier)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("no plan for %s/%s", inst.Deployment, tier)})
return
}
if !termSold(inst.Deployment, body.Term) {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("%s does not sell %s", inst.Deployment, body.Term)})
return
}
if body.Servers < plan.BaseLimits.MaxServers &&
plan.BaseLimits.MaxServers != -1 {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("%s includes %d servers; cannot configure fewer",
plan.Name, plan.BaseLimits.MaxServers)})
return
}
desired := models.Config{
Servers: body.Servers,
Features: models.Features(body.Features).OrEmpty(),
}
// Start from whatever is already granted, so writing a desired change never
// silently alters what the instance is currently allowed.
granted := desired
existing, err := models.GetEntitlement(ctx, inst.InstanceID)
switch {
case err == nil:
if !body.Grant {
granted = existing.Granted
}
case errors.Is(err, models.ErrNoEntitlement):
// First write. There is nothing granted to preserve, so desired becomes
// granted — an instance with an entitlement nobody has granted would
// fall back to the plan base at issue time and confuse everyone.
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Only the limits are stored. Features are NOT snapshotted onto the
// entitlement: they live in Granted.Features, and Issue resolves them again
// against the catalogue at signing time. Storing a second copy here would
// give two answers to "which features does this instance have".
limits, _, err := catalogue.Resolve(ctx, plan, granted)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ent := models.Entitlement{
InstanceID: inst.InstanceID,
AccountID: inst.AccountID,
Deployment: inst.Deployment,
Tier: tier,
Term: body.Term,
Desired: desired,
Granted: granted,
ResolvedLimits: limits,
}
// A reduction is a fact about the future, so it carries a date. There is no
// billing period to read yet — plan 5 sets this from the subscription — so
// staff-set reductions are marked as pending without one.
if desired.Servers < granted.Servers {
now := time.Now().UTC()
ent.ScheduledChangeAt = &now
}
if existing != nil {
ent.GrantedAt = existing.GrantedAt
}
if body.Grant {
ent.GrantedAt = time.Now().UTC()
}
if err := models.UpsertEntitlement(ctx, ent); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
audit.Write(ctx, models.AuditEntry{
Actor: auth.Current(c).Email,
Action: "entitlement.updated",
AccountID: inst.AccountID,
Target: inst.InstanceID,
Detail: fmt.Sprintf("tier=%s term=%s desired_servers=%d granted_servers=%d granted=%t",
tier, body.Term, desired.Servers, granted.Servers, body.Grant),
})
c.JSON(http.StatusOK, gin.H{"entitlement": ent, "pending": ent.Pending()})
}
+8 -8
View File
@@ -6,16 +6,16 @@ import (
"net/http"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/cloudprov"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/provision"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/mrhid6/vantage/admin/internal/audit"
"github.com/mrhid6/vantage/admin/internal/auth"
"github.com/mrhid6/vantage/admin/internal/cloudprov"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/models"
"github.com/mrhid6/vantage/shared/license"
sharedmodels "github.com/mrhid6/vantage/shared/models"
"github.com/mrhid6/vantage/shared/provision"
"go.mongodb.org/mongo-driver/v2/bson"
)
+64
View File
@@ -0,0 +1,64 @@
package api
import (
"encoding/json"
"io"
"log"
"net/http"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/billing"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/config"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/paddle"
"github.com/gin-gonic/gin"
)
// paddleWebhook is the ingress for every Paddle event.
//
// Order is load-bearing: read the RAW body first (the signature is over the
// exact bytes), verify, THEN claim the event ID, THEN dispatch. A bad signature
// is 401 and processes nothing; a duplicate of a handled event is 200 and does
// nothing; a handler error is 500 so Paddle retries, and is recorded for staff.
func paddleWebhook(cfg config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
body, err := io.ReadAll(c.Request.Body)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "unreadable body"})
return
}
if !paddle.VerifySignature(cfg.PaddleWebhookSecret,
c.GetHeader("Paddle-Signature"), body) {
log.Printf("paddle webhook: bad signature from %s", c.ClientIP())
c.JSON(http.StatusUnauthorized, gin.H{"error": "bad signature"})
return
}
var ev billing.Event
if err := json.Unmarshal(body, &ev); err != nil || ev.EventID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "malformed event"})
return
}
ctx := c.Request.Context()
claimed, err := models.ClaimEvent(ctx, ev.EventID, ev.EventType)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "claim failed"})
return
}
if !claimed {
// Already handled (or in flight). 200 so Paddle stops retrying.
c.JSON(http.StatusOK, gin.H{"duplicate": true})
return
}
if err := billing.Dispatch(ctx, ev); err != nil {
log.Printf("paddle webhook: handler %s failed for %s: %v",
ev.EventType, ev.EventID, err)
_ = models.MarkEventProcessed(ctx, ev.EventID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "handler failed"})
return
}
_ = models.MarkEventProcessed(ctx, ev.EventID, nil)
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
+6 -6
View File
@@ -7,13 +7,13 @@ import (
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/cloudprov"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/admin/internal/audit"
"github.com/mrhid6/vantage/admin/internal/auth"
"github.com/mrhid6/vantage/admin/internal/cloudprov"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/models"
sharedmodels "github.com/mrhid6/vantage/shared/models"
"go.mongodb.org/mongo-driver/v2/bson"
"golang.org/x/crypto/bcrypt"
)
+41 -4
View File
@@ -10,10 +10,10 @@ import (
"net/http"
"slices"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/config"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/admin/internal/auth"
"github.com/mrhid6/vantage/admin/internal/config"
"github.com/mrhid6/vantage/admin/internal/models"
)
func Routes(cfg config.Config) http.Handler {
@@ -39,6 +39,10 @@ func Routes(cfg config.Config) http.Handler {
r.POST("/auth/signup", auth.HandleSignup)
r.POST("/auth/accept-invite", auth.HandleAcceptInvite)
// Public: Paddle carries no session cookie; its signature is its auth. Must
// NOT sit under the cust group's session middleware.
r.POST("/api/paddle/webhook", paddleWebhook(cfg))
cust := r.Group("/api")
cust.Use(auth.RequireCustomer())
{
@@ -68,6 +72,29 @@ func Routes(cfg config.Config) http.Handler {
cust.POST("/instances/link", linkInstance)
cust.POST("/instances/:id/relink", relinkInstance)
cust.POST("/instances/:id/renew", renewInstance)
cust.POST("/instances/:id/claim-free",
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
claimFree)
// Renaming moves the instance's DNS host, so it is owner-or-admin like
// every other instance mutation. Cloud only; the handler refuses the rest.
cust.PUT("/instances/:id/name",
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
renameInstance)
cust.GET("/instances/:id/entitlement", getEntitlement)
cust.GET("/checkout/options", checkoutOptions)
// Paid self-hosted: links (or reuses) the customer's real install UUID so
// the checkout can name it. There is no placeholder and no claim step.
cust.POST("/instances/self-hosted",
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
createSelfHostedCheckout)
// Paid cloud: provisions a real instance the paid webhook then licenses.
cust.POST("/instances/cloud",
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
createCloudCheckout)
cust.PUT("/instances/:id/entitlement",
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
updateEntitlement)
cust.POST("/billing/portal", billingPortal)
cust.GET("/instances/:id/license", getInstanceLicense)
cust.GET("/instances/:id/license/download", downloadInstanceLicense)
cust.GET("/instances/:id/members", listInstanceMembers)
@@ -96,11 +123,21 @@ func Routes(cfg config.Config) http.Handler {
staff.GET("/subscriptions", staffListSubscriptions)
staff.POST("/instances/:id/issue", staffIssue)
staff.POST("/instances/:id/relink", staffRelink)
staff.PUT("/instances/:id/name", staffRenameInstance)
staff.GET("/licenses", staffListLicenses)
staff.GET("/plans", staffListPlans)
staff.PUT("/plans/:tier", staffUpdatePlan)
// Plans are keyed on the pair now, so the path is too. A single :tier
// segment could name three rows.
staff.PUT("/plans/:deployment/:tier", staffUpdatePlan)
staff.GET("/catalogue", staffListCatalogue)
staff.PUT("/catalogue", staffUpdateCatalogue)
staff.GET("/instances/:id/entitlement", staffGetEntitlement)
staff.PUT("/instances/:id/entitlement", staffSetEntitlement)
staff.GET("/audit", staffAudit)
staff.GET("/health/injection", staffInjectionHealth)
staff.GET("/health/billing", staffBillingHealth)
}
return r
+257 -19
View File
@@ -1,19 +1,25 @@
package api
import (
"context"
"errors"
"fmt"
"log"
"net/http"
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/cloudprov"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/licensing"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/provision"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/mrhid6/vantage/admin/internal/audit"
"github.com/mrhid6/vantage/admin/internal/auth"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/licensing"
"github.com/mrhid6/vantage/admin/internal/models"
"github.com/mrhid6/vantage/shared/license"
sharedmodels "github.com/mrhid6/vantage/shared/models"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
@@ -122,7 +128,11 @@ func staffListInstances(c *gin.Context) {
}
if c.Query("expiring") == "true" {
// Instances whose licence expires within 14 days, for renewal chasing.
var ids []string
//
// Empty rather than nil: a nil slice marshals to `$in: null`, which
// Mongo rejects outright, so the quiet week when nothing is expiring is
// exactly when this query would have failed.
ids := []string{}
cur, err := db.Admin("licenses").Find(c.Request.Context(), bson.M{
"superseded_by": bson.M{"$exists": false},
"expires_at": bson.M{"$lt": time.Now().UTC().Add(14 * 24 * time.Hour)},
@@ -375,6 +385,32 @@ func staffListLicenses(c *gin.Context) {
c.JSON(http.StatusOK, lics)
}
// staffBillingHealth surfaces webhook handlers that failed and placeholders
// still awaiting their instance, so a customer who paid and got nothing is
// visible rather than stuck in a support queue.
//
// Placeholders are a cloud-only path now; any self-hosted row still listed here
// predates the checkout change and needs issuing by hand.
func staffBillingHealth(c *gin.Context) {
ctx := c.Request.Context()
failed := []models.PaddleEvent{}
if cur, err := db.Admin("paddle_events").Find(ctx,
bson.M{"processed_at": bson.M{"$exists": false}, "error": bson.M{"$ne": ""}}); err == nil {
_ = cur.All(ctx, &failed)
}
unlinked := []models.Instance{}
if cur, err := db.Admin("admin_instances").Find(ctx,
bson.M{"placeholder": true, "status": models.StatusAwaitingLink}); err == nil {
_ = cur.All(ctx, &unlinked)
}
c.JSON(http.StatusOK, gin.H{
"failed_events": failed,
"failed_count": len(failed),
"unlinked_paid": unlinked,
"unlinked_count": len(unlinked),
})
}
func staffListPlans(c *gin.Context) {
cur, err := db.Admin("plans").Find(c.Request.Context(), bson.M{})
if err != nil {
@@ -389,31 +425,130 @@ func staffListPlans(c *gin.Context) {
c.JSON(http.StatusOK, plans)
}
// staffUpdatePlan changes what a tier grants FROM NOW ON. Existing licences
// snapshotted their plan at issue time and are unaffected — the same rule as
// workflow_runs.steps_snapshot.
// staffUpdatePlan changes what a (deployment, tier) pair grants FROM NOW ON.
// Existing licences snapshotted their plan at issue time and are unaffected —
// the same rule as workflow_runs.steps_snapshot.
//
// It writes no Paddle identifiers: those live in the catalogue, because a
// metered plan is priced by several components.
func staffUpdatePlan(c *gin.Context) {
var body models.Plan
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid plan"})
return
}
deployment, tier := c.Param("deployment"), c.Param("tier")
set := bson.M{
"name": body.Name,
"limits": body.Limits,
"features": body.Features,
"paddle_product_id": body.PaddleProductID,
"paddle_price_ids": body.PaddlePriceIDs,
"active": body.Active,
"name": body.Name,
"base_limits": body.BaseLimits,
"base_features": body.BaseFeatures.OrEmpty(),
"support_level": body.SupportLevel,
"active": body.Active,
}
if _, err := db.Admin("plans").UpdateOne(c.Request.Context(),
bson.M{"tier": c.Param("tier")}, bson.M{"$set": set}); err != nil {
res, err := db.Admin("plans").UpdateOne(c.Request.Context(),
bson.M{"deployment": deployment, "tier": tier}, bson.M{"$set": set})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if res.MatchedCount == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "no such plan"})
return
}
audit.Write(c.Request.Context(), models.AuditEntry{
Actor: auth.Current(c).Email,
Action: "plan.updated",
Target: deployment + "/" + tier,
Detail: fmt.Sprintf("servers=%d monitors=%d support=%s active=%t",
body.BaseLimits.MaxServers, body.BaseLimits.MaxMonitors,
body.SupportLevel, body.Active),
})
c.JSON(http.StatusOK, gin.H{"updated": true})
}
func staffListCatalogue(c *gin.Context) {
rows, err := models.AllCatalogue(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, rows)
}
// staffUpdateCatalogue sets the price IDs on one component.
//
// The component is addressed by its natural key rather than by an ObjectID, so
// the staff UI never has to hold a Mongo identifier and a seeded row can be
// updated the moment it exists. Only price IDs are writable: a row's kind, plan
// and key are seeded by SeedCatalogue, and letting a form invent a limit_key
// would let it invent a limit nothing enforces.
func staffUpdateCatalogue(c *gin.Context) {
var body struct {
Kind string `json:"kind"`
Deployment string `json:"deployment"`
Tier string `json:"tier"`
LimitKey string `json:"limit_key"`
FeatureKey string `json:"feature_key"`
PriceIDs map[string]map[string]string `json:"price_ids"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid component"})
return
}
// Refuse a price on a term the deployment does not sell. Storing one would
// mean a resolved self-hosted monthly price later, which the resolver treats
// as a configuration error — better to refuse it at the point somebody
// pastes it, while they are looking at the screen.
for env, byTerm := range body.PriceIDs {
for term, id := range byTerm {
if id == "" {
continue
}
if !termSold(body.Deployment, term) {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("%s does not sell %s (environment %s)",
body.Deployment, term, env)})
return
}
}
}
filter := bson.M{
"kind": body.Kind,
"deployment": body.Deployment,
"tier": body.Tier,
"limit_key": body.LimitKey,
"feature_key": body.FeatureKey,
}
res, err := db.Admin("catalogue").UpdateOne(c.Request.Context(), filter,
bson.M{"$set": bson.M{"price_ids": body.PriceIDs}})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if res.MatchedCount == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "no such component"})
return
}
audit.Write(c.Request.Context(), models.AuditEntry{
Actor: auth.Current(c).Email,
Action: "catalogue.updated",
Target: body.Deployment + "/" + body.Tier + "/" + body.Kind,
Detail: body.LimitKey + body.FeatureKey,
})
c.JSON(http.StatusOK, gin.H{"updated": true})
}
func termSold(deployment, term string) bool {
for _, t := range license.TermsFor(deployment) {
if t == term {
return true
}
}
return false
}
func staffAudit(c *gin.Context) {
filter := bson.M{}
if v := c.Query("account_id"); v != "" {
@@ -488,3 +623,106 @@ func staffCreateAccountUser(c *gin.Context) {
Actor: s.Email, Action: "customer_user.created", AccountID: accountID, Target: email})
c.JSON(http.StatusCreated, gin.H{"pending": true})
}
// staffRenameInstance renames any instance, with no cooldown.
//
// It does NOT write renamed_at: a staff rename must not start the customer's
// 24h clock, or fixing a name for someone locks them out of fixing it further.
//
// On self-hosted it changes admin's label only. There is no control-plane row to
// write — the install is the customer's — and no slug, because self-hosted has
// no tenant subdomain.
//
// A cloud placeholder is refused outright rather than relabelled: it has no
// control-plane row yet, so a label-only rename here would be a name that the
// instance never gets when provisioning finally derives its slug from the
// checkout's name. The customer endpoint refuses it for the same reason.
func staffRenameInstance(c *gin.Context) {
var body struct {
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
return
}
name := strings.TrimSpace(body.Name)
if name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
return
}
ctx := c.Request.Context()
var inst models.Instance
if err := db.Admin("admin_instances").FindOne(ctx,
bson.M{"instance_id": c.Param("id")}).Decode(&inst); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if inst.Deployment == license.DeploymentCloud && inst.Placeholder {
c.JSON(http.StatusConflict, gin.H{"error": "this instance is not provisioned yet"})
return
}
// The unwind and the audit write must survive the request being cancelled:
// an unwind on a dead context leaves the two databases disagreeing, which is
// the failure the unwind exists for.
//
// Only the cancellation is detached here; each deadline is derived at its use
// site below. A deadline started before the forward work is a deadline the
// unwind may never get to use — a control plane slow enough to make the admin
// write fail is exactly the one that would have spent it already.
detached := context.WithoutCancel(ctx)
set := bson.M{"name": name}
slug := inst.Slug
cloud := inst.Deployment == license.DeploymentCloud
// The control plane's own previous values, not admin's copy: admin's may be
// stale, and its slug is omitempty, so unwinding from it can write an empty
// slug into instances.
prevName, prevSlug := inst.Name, inst.Slug
if cloud {
renamed, pName, pSlug, err := cloudprov.RenameInstance(ctx, inst.InstanceID, name)
switch {
case errors.Is(err, provision.ErrSlugTaken):
c.JSON(http.StatusConflict, gin.H{"error": "that name is already in use"})
return
case errors.Is(err, provision.ErrNameRejected):
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
return
case err != nil:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
prevName, prevSlug = pName, pSlug
slug = renamed.Slug
set["slug"] = renamed.Slug
}
// A matched count of zero is the same failure quietly: the control plane
// moved and admin's row did not.
res, err := db.Admin("admin_instances").UpdateOne(ctx,
bson.M{"instance_id": inst.InstanceID}, bson.M{"$set": set})
if err == nil && res.MatchedCount == 0 {
err = errors.New("admin_instances row matched nothing")
}
if err != nil {
if cloud {
rbCtx, rbCancel := context.WithTimeout(detached, 5*time.Second)
if rbErr := cloudprov.RestoreInstanceIdentity(rbCtx, inst.InstanceID, prevName, prevSlug); rbErr != nil {
log.Printf("staffRenameInstance: rollback of %s failed: %v", inst.InstanceID, rbErr)
}
rbCancel()
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
auCtx, auCancel := context.WithTimeout(detached, 5*time.Second)
audit.Write(auCtx, models.AuditEntry{
Actor: auth.Current(c).Email, Action: "instance.renamed", AccountID: inst.AccountID,
Target: inst.InstanceID, Detail: prevSlug + " -> " + slug, IP: c.ClientIP()})
auCancel()
c.JSON(http.StatusOK, gin.H{"instance_id": inst.InstanceID, "name": name, "slug": slug})
}
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"log"
"time"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
)
// Write never returns an error: an audit failure must not roll back the action
+12 -7
View File
@@ -10,12 +10,13 @@ import (
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/mail"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
sharedmail "gitea.hostxtra.co.uk/mrhid6/vantage/shared/mail"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/mrhid6/vantage/admin/internal/audit"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/mail"
"github.com/mrhid6/vantage/admin/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
"golang.org/x/crypto/bcrypt"
)
@@ -26,7 +27,11 @@ const BcryptCost = 12
// VerifyWindow mirrors sitesvc's proven pattern: 32 random bytes, only the
// SHA-256 hash stored, 24-hour expiry.
const VerifyWindow = 24 * time.Hour
//
// It is shared/mail's constant rather than a second copy because the
// verification email states the number of hours: a window that disagreed with
// what the email promised would expire links early with no explanation.
const VerifyWindow = sharedmail.VerifyWindow
// CreateCustomerUser creates an unverified HQ login with a chosen password and
// emails the verification link. Used by signup and by staff.
@@ -58,7 +63,7 @@ func CreateCustomerUser(ctx context.Context, accountID, email, password, account
return err
}
if err := mail.SendVerification(u.Email, token); err != nil {
if err := mail.Default.SendVerification(u.Email, token); err != nil {
// Undo the insert. A row whose verification link was never delivered is
// worse than no row: it can never be signed in to, and it holds the
// unique index on email, so the customer cannot sign up again with the
@@ -109,7 +114,7 @@ func CreateInvitedUser(ctx context.Context, accountID, accountName, email, accou
return err
}
if err := mail.SendInvite(u.Email, accountName, token); err != nil {
if err := mail.Default.SendInvite(u.Email, accountName, token); err != nil {
// Same rollback rule, and the same detached context, as signup: a row
// whose link was never delivered can never be signed in to and holds
// the unique index on email against the person it was meant for.
+2 -2
View File
@@ -4,9 +4,9 @@ import (
"net/http"
"slices"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
)
+3 -3
View File
@@ -4,10 +4,10 @@ import (
"net/http"
"strings"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/admin/internal/audit"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
"golang.org/x/crypto/bcrypt"
)
+196
View File
@@ -0,0 +1,196 @@
package billing
import (
"context"
"fmt"
"log"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/catalogue"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/cloudprov"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/paddle"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
"github.com/google/uuid"
"go.mongodb.org/mongo-driver/v2/bson"
)
// placeholderReconcileInterval is how often placeholders are swept: paid cloud
// ones a failed webhook left unprovisioned are completed, and stale unpaid ones
// of either deployment are reaped.
const placeholderReconcileInterval = 5 * time.Minute
// abandonedPlaceholderAfter is how long an unpaid placeholder may sit before it
// is treated as an abandoned checkout and deleted. Comfortably longer than a
// webhook's delivery lag, so a just-paid placeholder awaiting its subscription
// event is never mistaken for an abandoned one.
const abandonedPlaceholderAfter = 24 * time.Hour
// StartPlaceholderReconciler owns the after-checkout lifecycle of placeholders.
//
// It recovers the one failure the webhook cannot on its own — a confirmed payment
// whose provisioning handler errored, which is not retried once its event is
// claimed and which the inject reconciler (licences only) does not repair — by
// completing paid cloud placeholders here. And it reaps abandoned ones: a
// placeholder with no subscription past abandonedPlaceholderAfter is a checkout
// nobody finished, and deleting it loses nothing, because a placeholder has no
// control-plane footprint until it is paid for and provisioned.
func StartPlaceholderReconciler(ctx context.Context) {
go func() {
t := time.NewTicker(placeholderReconcileInterval)
defer t.Stop()
reconcilePlaceholders(ctx)
for {
select {
case <-ctx.Done():
return
case <-t.C:
reconcilePlaceholders(ctx)
}
}
}()
}
func reconcilePlaceholders(ctx context.Context) {
cur, err := db.Admin("admin_instances").Find(ctx, bson.M{"placeholder": true})
if err != nil {
log.Printf("placeholder reconcile: query: %v", err)
return
}
var placeholders []models.Instance
if err := cur.All(ctx, &placeholders); err != nil {
log.Printf("placeholder reconcile: decode: %v", err)
return
}
now := time.Now().UTC()
for _, inst := range placeholders {
var sub models.Subscription
paid := db.Admin("subscriptions").FindOne(ctx,
bson.M{"instance_id": inst.InstanceID, "status": models.SubActive}).Decode(&sub) == nil
if !paid {
// Never paid for. Reap once it is old enough to be an abandoned
// checkout rather than one still awaiting its subscription webhook.
if now.Sub(inst.CreatedAt) > abandonedPlaceholderAfter {
if _, err := db.Admin("admin_instances").DeleteOne(ctx,
bson.M{"instance_id": inst.InstanceID, "placeholder": true}); err != nil {
log.Printf("placeholder reconcile: reap abandoned %s: %v", inst.InstanceID, err)
} else {
log.Printf("placeholder reconcile: reaped abandoned placeholder %s", inst.InstanceID)
}
}
continue
}
// Paid, self-hosted: nothing to provision — the customer installs and
// links, and lifecycle chases them. Only cloud is completed here.
if inst.Deployment != license.DeploymentCloud {
continue
}
items := make([]catalogue.Item, 0, len(sub.Items))
for _, it := range sub.Items {
items = append(items, catalogue.Item{PriceID: it.PriceID, Quantity: it.Quantity})
}
match, err := catalogue.ResolveItems(ctx, paddle.Get().Env(), items)
if err != nil {
log.Printf("placeholder reconcile: resolve items for %s: %v", inst.InstanceID, err)
continue
}
provisioned, err := completeCloudPlaceholder(ctx, &inst)
if err != nil {
log.Printf("placeholder reconcile: complete %s: %v", inst.InstanceID, err)
continue
}
if err := promoteAndIssue(ctx, provisioned, match, models.ReasonNew); err != nil {
log.Printf("placeholder reconcile: issue %s: %v", inst.InstanceID, err)
continue
}
log.Printf("placeholder reconcile: completed paid cloud instance %s", inst.InstanceID)
}
}
// completeCloudPlaceholder provisions the cloud instance a paid placeholder stands
// for, once payment is confirmed, and returns the row promoted to a real instance.
//
// It is the payment-first half of the paid-cloud flow: createCloudCheckout made
// the placeholder before payment, this provisions it after. The control-plane
// instance is created with the placeholder's OWN id (cloudprov.CreateInstanceWithID),
// so nothing is rewritten and the subscription's custom_data still resolves this
// row on every later webhook.
//
// Every step is idempotent, because a webhook can be retried after this partly
// ran: provisioning converges rather than duplicates, and the row flip and
// membership insert are guarded on what they write. The caller then issues.
func completeCloudPlaceholder(ctx context.Context, inst *models.Instance) (*models.Instance, error) {
cu, err := placeholderOwner(ctx, inst)
if err != nil {
return nil, err
}
prov, err := cloudprov.CreateInstanceWithID(ctx, inst.InstanceID, inst.Name,
cu.Email, cu.PasswordHash, cu.UserID)
if err != nil {
return nil, fmt.Errorf("provision cloud instance %s: %w", inst.InstanceID, err)
}
if _, err := db.Admin("admin_instances").UpdateOne(ctx,
bson.M{"instance_id": inst.InstanceID},
bson.M{
"$set": bson.M{"slug": prov.Slug, "status": models.StatusActive, "placeholder": false},
"$unset": bson.M{"pending_owner_user_id": ""},
}); err != nil {
return nil, fmt.Errorf("promote placeholder %s: %w", inst.InstanceID, err)
}
// Record the owner's membership. Best-effort and guarded on absence: the
// projected user is what grants access, so a missing row costs a line in the
// members panel, not access — and the boot backfill rebuilds it.
if ownerID, err := cloudprov.OwnerUserID(ctx, inst.InstanceID); err == nil {
if n, _ := db.Admin("instance_members").CountDocuments(ctx,
bson.M{"instance_id": inst.InstanceID, "customer_user_id": cu.UserID}); n == 0 {
if _, err := db.Admin("instance_members").InsertOne(ctx, models.InstanceMember{
MemberID: uuid.NewString(),
AccountID: inst.AccountID,
InstanceID: inst.InstanceID,
CustomerUserID: cu.UserID,
ControlUserID: ownerID,
Role: sharedmodels.RoleOwner,
Email: cu.Email,
CreatedAt: time.Now().UTC(),
}); err != nil {
log.Printf("completeCloudPlaceholder: record owner membership for %s: %v",
inst.InstanceID, err)
}
}
}
next := *inst
next.Slug = prov.Slug
next.Status = models.StatusActive
next.Placeholder = false
next.PendingOwnerUserID = ""
return &next, nil
}
// placeholderOwner resolves the customer_user who should own a provisioned cloud
// placeholder: the buyer recorded at checkout, or the account owner if that
// pointer is somehow missing.
func placeholderOwner(ctx context.Context, inst *models.Instance) (*models.CustomerUser, error) {
var cu models.CustomerUser
if inst.PendingOwnerUserID != "" {
if err := db.Admin("customer_users").FindOne(ctx,
bson.M{"user_id": inst.PendingOwnerUserID}).Decode(&cu); err == nil {
return &cu, nil
}
}
if err := db.Admin("customer_users").FindOne(ctx,
bson.M{"account_id": inst.AccountID, "account_role": models.AccountRoleOwner}).Decode(&cu); err != nil {
return nil, fmt.Errorf("no owner for account %s to provision %s: %w",
inst.AccountID, inst.InstanceID, err)
}
return &cu, nil
}
+24
View File
@@ -0,0 +1,24 @@
package billing
import (
"context"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/inject"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/mail"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
)
// deliver sends a freshly issued licence where it belongs. Cloud is injected;
// self-hosted is emailed the blob (their database is theirs). This mirrors the
// api-side deliver helper but takes no gin context — webhooks have none, and the
// customer is not on the other end of the request.
func deliver(ctx context.Context, inst *models.Instance, lic *models.License, to string) {
if inst.Deployment == license.DeploymentCloud {
inject.Deliver(ctx, lic)
return
}
if to != "" && mail.Enabled() {
_ = mail.Default.SendLicense(to, inst.Name, lic.Blob)
}
}
+51
View File
@@ -0,0 +1,51 @@
// Package billing turns verified Paddle webhooks into licence actions. It never
// verifies signatures (that is paddle.VerifySignature at the edge) and never
// signs (that is licensing.Issue); it decides what a subscription's current
// state means and calls the issuer.
package billing
import (
"context"
"encoding/json"
"fmt"
"time"
)
// Event is the decoded Paddle webhook envelope. Data is left raw so each handler
// decodes only the shape it needs.
type Event struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
OccurredAt time.Time `json:"occurred_at"`
Data json.RawMessage `json:"data"`
}
// Dispatch routes one event to its handler. Unknown event types are a no-op
// success: Paddle sends many we do not care about, and 200 stops it retrying.
func Dispatch(ctx context.Context, ev Event) error {
switch ev.EventType {
case "subscription.created", "subscription.updated", "subscription.activated":
return handleSubscription(ctx, ev)
case "subscription.canceled":
return handleCanceled(ctx, ev)
case "subscription.past_due":
return handlePastDue(ctx, ev)
case "transaction.completed":
return handleTransactionCompleted(ctx, ev)
case "transaction.payment_failed":
return handlePaymentFailed(ctx, ev)
case "customer.updated":
return handleCustomerUpdated(ctx, ev)
default:
return nil
}
}
// decode is a small helper so every handler decodes Data the same way.
func decode[T any](ev Event) (T, error) {
var v T
if err := json.Unmarshal(ev.Data, &v); err != nil {
return v, fmt.Errorf("decode %s: %w", ev.EventType, err)
}
return v, nil
}
+299
View File
@@ -0,0 +1,299 @@
package billing
import (
"context"
"errors"
"fmt"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/catalogue"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/licensing"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/mail"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/paddle"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"github.com/google/uuid"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
// subscriptionData is the slice of Paddle's subscription payload we read. Fields
// we ignore are simply absent — encoding/json drops them.
type subscriptionData struct {
ID string `json:"id"`
CustomerID string `json:"customer_id"`
Status string `json:"status"`
CustomData struct {
AccountID string `json:"account_id"`
InstanceID string `json:"instance_id"`
} `json:"custom_data"`
CurrentBillingPeriod struct {
EndsAt time.Time `json:"ends_at"`
} `json:"current_billing_period"`
Items []struct {
Price struct {
ID string `json:"id"`
} `json:"price"`
Quantity int `json:"quantity"`
} `json:"items"`
}
func (d subscriptionData) lineItems() []catalogue.Item {
items := make([]catalogue.Item, 0, len(d.Items))
for _, it := range d.Items {
items = append(items, catalogue.Item{PriceID: it.Price.ID, Quantity: it.Quantity})
}
return items
}
// handleSubscription folds created/updated/activated into one job: make the
// world match the subscription's CURRENT state. That is what keeps out-of-order
// delivery correct — an updated arriving before its created still carries the
// full item list, so reading all of it is reading current state, not a
// transition.
func handleSubscription(ctx context.Context, ev Event) error {
d, err := decode[subscriptionData](ev)
if err != nil {
return err
}
if d.CustomData.InstanceID == "" {
return fmt.Errorf("subscription %s has no instance_id in custom_data", d.ID)
}
match, err := catalogue.ResolveItems(ctx, paddle.Get().Env(), d.lineItems())
if err != nil {
// A price we cannot map is a configuration error, not a customer error.
// Fail loudly so it is retried and surfaced rather than guessed.
return fmt.Errorf("resolve items for subscription %s: %w", d.ID, err)
}
// Resolve BEFORE recording. custom_data names whatever id the checkout was
// opened against, and a relink since then has rewritten the instance's
// identity and patched Paddle — but that patch is best-effort and any event
// already in flight still carries the old id. Writing it straight through
// would revert the subscription row and then fail to find the instance,
// wedging every renewal.
instanceID, inst, err := resolveInstance(ctx, d.CustomData.InstanceID)
if err != nil {
return fmt.Errorf("subscription %s names unknown instance %s: %w",
d.ID, d.CustomData.InstanceID, err)
}
sub := models.Subscription{
AccountID: d.CustomData.AccountID,
InstanceID: instanceID,
PaddleSubscriptionID: d.ID,
Tier: match.Tier,
Term: match.Term,
Status: d.Status,
CurrentPeriodEnd: d.CurrentBillingPeriod.EndsAt,
Items: toSubItems(d.lineItems()),
}
if err := upsertSubscription(ctx, sub); err != nil {
return err
}
// Learn the Paddle customer ID onto the account the first time we see it.
if d.CustomerID != "" && d.CustomData.AccountID != "" {
_, _ = db.Admin("accounts").UpdateOne(ctx,
bson.M{"account_id": d.CustomData.AccountID, "paddle_customer_id": bson.M{"$in": bson.A{nil, ""}}},
bson.M{"$set": bson.M{"paddle_customer_id": d.CustomerID}})
}
// A cloud placeholder is the payment-first path: the instance does not exist
// until this confirmed-payment event, so it is provisioned here and then
// issued (first term). Self-hosted has no placeholder — its checkout named
// the install's real UUID — so it falls straight through to issuance.
// An instance with no licence yet is a first purchase, not a change of plan.
// Self-hosted reaches that state through an ordinary link, so the placeholder
// flag no longer answers this on its own.
reason := models.ReasonEntitlementChange
if inst.CurrentLicense == "" {
reason = models.ReasonNew
}
if inst.Placeholder {
if inst.Deployment != license.DeploymentCloud {
return fmt.Errorf("instance %s is a non-cloud placeholder, which no longer exists", inst.InstanceID)
}
provisioned, err := completeCloudPlaceholder(ctx, &inst)
if err != nil {
return err
}
inst = *provisioned
reason = models.ReasonNew
}
return promoteAndIssue(ctx, &inst, match, reason)
}
// resolveInstance finds the instance a webhook's custom_data names, following the
// identity trail when the id is one a relink or a cloud placeholder's
// provisioning has since replaced. It returns the instance's CURRENT id, which is the only id anything
// else should be written against.
func resolveInstance(ctx context.Context, customDataID string) (string, models.Instance, error) {
var inst models.Instance
err := db.Admin("admin_instances").FindOne(ctx,
bson.M{"instance_id": customDataID}).Decode(&inst)
if err == nil {
return inst.InstanceID, inst, nil
}
if !errors.Is(err, mongo.ErrNoDocuments) {
return "", inst, err
}
if err := db.Admin("admin_instances").FindOne(ctx,
bson.M{"previous_instance_ids": customDataID}).Decode(&inst); err != nil {
return "", inst, err
}
return inst.InstanceID, inst, nil
}
// promoteAndIssue promotes desired→granted from the resolved match, then signs a
// licence from granted. This is the only promotion path other than the staff
// grant, and it exists because a webhook is a confirmed payment.
func promoteAndIssue(ctx context.Context, inst *models.Instance, match catalogue.Match, reason string) error {
plan, err := models.GetPlan(ctx, inst.Deployment, match.Tier)
if err != nil {
return fmt.Errorf("no plan for %s/%s: %w", inst.Deployment, match.Tier, err)
}
granted := models.Config{
Servers: match.Servers,
Features: models.Features(match.Features).OrEmpty(),
}
limits, _, err := catalogue.Resolve(ctx, plan, granted)
if err != nil {
return err
}
if err := models.UpsertEntitlement(ctx, models.Entitlement{
InstanceID: inst.InstanceID,
AccountID: inst.AccountID,
Deployment: inst.Deployment,
Tier: match.Tier,
Term: match.Term,
Desired: granted,
Granted: granted,
ResolvedLimits: limits,
}); err != nil {
return err
}
lic, err := licensing.Issue(ctx, licensing.IssueInput{
InstanceID: inst.InstanceID,
Tier: match.Tier,
Term: match.Term,
Reason: reason,
IssuedBy: "paddle",
})
if err != nil {
return fmt.Errorf("issue for %s: %w", inst.InstanceID, err)
}
deliver(ctx, inst, lic, billingEmailFor(ctx, inst.AccountID))
return nil
}
// handleCanceled marks the SUBSCRIPTION cancelled and takes NO licence action.
//
// The instance stays active until its licence expires, when the existing
// lifecycle sweep lapses it. Flipping the instance to cancelled here would stop
// inject.Reconcile and the sweep repairing a licence that is still valid — the
// opposite of "keeps working until it expires".
func handleCanceled(ctx context.Context, ev Event) error {
d, err := decode[subscriptionData](ev)
if err != nil {
return err
}
if _, err := db.Admin("subscriptions").UpdateOne(ctx,
bson.M{"paddle_subscription_id": d.ID},
bson.M{"$set": bson.M{"status": models.SubCanceled}}); err != nil {
return err
}
if to := billingEmailFor(ctx, d.CustomData.AccountID); to != "" {
_ = mail.Default.SendCancelled(to, instanceNameFor(ctx, d.CustomData.InstanceID))
}
return nil
}
// handlePastDue flags the subscription and notifies, but leaves the licence
// alone. Dunning is Paddle's; ours is not to punish a retryable card failure.
func handlePastDue(ctx context.Context, ev Event) error {
d, err := decode[subscriptionData](ev)
if err != nil {
return err
}
if _, err := db.Admin("subscriptions").UpdateOne(ctx,
bson.M{"paddle_subscription_id": d.ID},
bson.M{"$set": bson.M{"status": models.SubPastDue}}); err != nil {
return err
}
if to := billingEmailFor(ctx, d.CustomData.AccountID); to != "" {
_ = mail.Default.SendPastDue(to, instanceNameFor(ctx, d.CustomData.InstanceID))
}
return nil
}
// handleCustomerUpdated syncs the billing email onto the account.
func handleCustomerUpdated(ctx context.Context, ev Event) error {
d, err := decode[struct {
ID string `json:"id"`
Email string `json:"email"`
}](ev)
if err != nil {
return err
}
if d.ID == "" || d.Email == "" {
return nil
}
_, err = db.Admin("accounts").UpdateOne(ctx,
bson.M{"paddle_customer_id": d.ID},
bson.M{"$set": bson.M{"billing_email": d.Email}})
return err
}
func toSubItems(items []catalogue.Item) []models.SubItem {
out := make([]models.SubItem, 0, len(items))
for _, it := range items {
out = append(out, models.SubItem{PriceID: it.PriceID, Quantity: it.Quantity})
}
return out
}
func upsertSubscription(ctx context.Context, sub models.Subscription) error {
_, err := db.Admin("subscriptions").UpdateOne(ctx,
bson.M{"paddle_subscription_id": sub.PaddleSubscriptionID},
bson.M{"$set": bson.M{
"account_id": sub.AccountID,
"instance_id": sub.InstanceID,
"tier": sub.Tier,
"term": sub.Term,
"status": sub.Status,
"current_period_end": sub.CurrentPeriodEnd,
"items": sub.Items,
}, "$setOnInsert": bson.M{
"subscription_id": uuid.NewString(),
"paddle_subscription_id": sub.PaddleSubscriptionID,
}},
options.UpdateOne().SetUpsert(true))
return err
}
// billingEmailFor reads the account's billing email for self-hosted delivery.
func billingEmailFor(ctx context.Context, accountID string) string {
var acc models.Account
if err := db.Admin("accounts").FindOne(ctx,
bson.M{"account_id": accountID}).Decode(&acc); err != nil {
return ""
}
return acc.BillingEmail
}
// instanceNameFor is a best-effort display name for an email subject.
func instanceNameFor(ctx context.Context, instanceID string) string {
// Alias-aware: a cancellation can name an id a relink has replaced, and "your instance"
// in place of the name the customer chose reads like the wrong email.
_, inst, err := resolveInstance(ctx, instanceID)
if err != nil || inst.Name == "" {
return "your instance"
}
return inst.Name
}
+124
View File
@@ -0,0 +1,124 @@
package billing
import (
"context"
"fmt"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/catalogue"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/paddle"
"go.mongodb.org/mongo-driver/v2/bson"
)
type transactionData struct {
ID string `json:"id"`
SubscriptionID string `json:"subscription_id"`
Origin string `json:"origin"`
Items []struct {
Price struct {
ID string `json:"id"`
} `json:"price"`
Quantity int `json:"quantity"`
} `json:"items"`
}
// handleTransactionCompleted issues the next term's licence on a renewal.
//
// A renewal is the one moment a scheduled REDUCTION takes effect: the customer's
// desired (smaller) configuration becomes granted. Mid-term reductions never
// shrink a live licence. On a first charge (origin not recurring) the
// subscription.created/updated handler already issued, so this is a no-op to
// avoid a double issue.
func handleTransactionCompleted(ctx context.Context, ev Event) error {
d, err := decode[transactionData](ev)
if err != nil {
return err
}
if d.Origin != "subscription_recurring" {
return nil
}
if d.SubscriptionID == "" {
return fmt.Errorf("renewal transaction %s has no subscription_id", d.ID)
}
var sub models.Subscription
if err := db.Admin("subscriptions").FindOne(ctx,
bson.M{"paddle_subscription_id": d.SubscriptionID}).Decode(&sub); err != nil {
return fmt.Errorf("renewal for unknown subscription %s: %w", d.SubscriptionID, err)
}
var inst models.Instance
if err := db.Admin("admin_instances").FindOne(ctx,
bson.M{"instance_id": sub.InstanceID}).Decode(&inst); err != nil {
return fmt.Errorf("renewal names unknown instance %s: %w", sub.InstanceID, err)
}
// Prefer the transaction's own item list (authoritative for this period);
// fall back to the subscription's recorded items.
items := make([]catalogue.Item, 0, len(d.Items))
for _, it := range d.Items {
items = append(items, catalogue.Item{PriceID: it.Price.ID, Quantity: it.Quantity})
}
if len(items) == 0 {
for _, it := range sub.Items {
items = append(items, catalogue.Item{PriceID: it.PriceID, Quantity: it.Quantity})
}
}
match, err := catalogue.ResolveItems(ctx, paddle.Get().Env(), items)
if err != nil {
return fmt.Errorf("resolve renewal items for %s: %w", d.SubscriptionID, err)
}
// Collapse a scheduled reduction: desired becomes granted, and the pending
// marker is cleared, since a new term has begun. This is the only place a
// licence ever gets a smaller cap.
if err := promoteScheduledReduction(ctx, inst.InstanceID); err != nil {
return err
}
// Issue the next term. Renewal resets relink_count inside licensing.Issue.
if err := promoteAndIssue(ctx, &inst, match, models.ReasonRenewal); err != nil {
return err
}
// Clear lifecycle notices so the next term starts the sequence fresh (mirrors
// the self-serve renew path).
_, _ = db.Admin("admin_instances").UpdateOne(ctx,
bson.M{"instance_id": inst.InstanceID},
bson.M{"$unset": bson.M{"notices_sent": ""}})
return nil
}
// promoteScheduledReduction collapses a pending reduction into granted at
// renewal and clears scheduled_change_at. A no-op when nothing is pending — the
// match resolved from the renewal's items is authoritative either way, so this
// only keeps the entitlement's own bookkeeping honest.
func promoteScheduledReduction(ctx context.Context, instanceID string) error {
ent, err := models.GetEntitlement(ctx, instanceID)
if err != nil {
return nil // no entitlement to reconcile
}
if ent.ScheduledChangeAt == nil {
return nil
}
ent.Granted = ent.Desired
ent.ScheduledChangeAt = nil
return models.UpsertEntitlement(ctx, *ent)
}
// handlePaymentFailed records the failure for staff visibility. No licence
// action — the licence runs to its (grace-padded) expiry and Paddle retries.
func handlePaymentFailed(ctx context.Context, ev Event) error {
d, err := decode[transactionData](ev)
if err != nil {
return err
}
if d.SubscriptionID == "" {
return nil
}
_, err = db.Admin("subscriptions").UpdateOne(ctx,
bson.M{"paddle_subscription_id": d.SubscriptionID},
bson.M{"$set": bson.M{"status": models.SubPastDue}})
return err
}
+120
View File
@@ -0,0 +1,120 @@
// Package catalogue turns an entitlement into the two things derived from it:
// the limits and features a licence grants, and the Paddle line items a
// subscription is made of.
//
// Both folds live here so the arithmetic exists once. The temptation is to
// compute limits in the issuer and quantities in the checkout, and then the two
// disagree about whether the base allowance is included in the number — which is
// a bug that bills a customer for three servers they were given.
package catalogue
import (
"context"
"errors"
"fmt"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
)
var (
// ErrUnknownPrice means an item named a price ID no catalogue row claims.
//
// This is always a configuration error and never a customer error: someone
// bought something at a price we cannot map to a plan. It must fail loudly
// rather than guess a tier — a guessed tier is a wrong licence with no
// record of why.
ErrUnknownPrice = errors.New("no catalogue row claims that price ID")
// ErrNoBaseItem means no item matched a base row, so the subscription names
// no plan. Quantities are meaningless without one.
ErrNoBaseItem = errors.New("no item matches a base price; the subscription names no plan")
// ErrTermNotSold means a price resolved to a term its deployment does not
// sell — in practice a self-hosted monthly price.
ErrTermNotSold = errors.New("that deployment does not sell that term")
// ErrUnpriced means a component needed for this configuration has no price
// ID in this environment. Refusing is correct: a checkout that silently
// drops a paid line item gives away the thing it was meant to charge for.
ErrUnpriced = errors.New("component has no price in this environment")
)
// Resolve folds a configuration into the limits and features a licence grants.
//
// Limits start at the plan's base and each metered component adds its configured
// amount. Features are the plan's base features plus the configured ones,
// deduplicated and filtered to keys the catalogue actually offers — a stale
// feature key in a stored entitlement must not survive into a signed payload.
func Resolve(ctx context.Context, plan *models.Plan, cfg models.Config) (license.Limits, []string, error) {
rows, err := models.CatalogueFor(ctx, plan.Deployment, plan.Tier)
if err != nil {
return license.Limits{}, nil, err
}
limits := plan.BaseLimits
offered := map[string]bool{}
for _, r := range rows {
switch r.Kind {
case models.KindLimit:
if err := addLimit(&limits, r.LimitKey, configured(cfg, r.LimitKey), plan.BaseLimits); err != nil {
return license.Limits{}, nil, err
}
case models.KindFeature:
offered[r.FeatureKey] = true
}
}
seen := map[string]bool{}
features := []string{}
for _, f := range plan.BaseFeatures {
if !seen[f] {
seen[f] = true
features = append(features, f)
}
}
for _, f := range cfg.Features {
if seen[f] || !offered[f] {
continue
}
seen[f] = true
features = append(features, f)
}
return limits, features, nil
}
// configured reads the configured total for one metered limit key.
//
// A switch rather than reflection, so every metered dimension is greppable and
// adding one is a visible edit here as well as a catalogue row.
func configured(cfg models.Config, limitKey string) int {
switch limitKey {
case models.LimitKeyServers:
return cfg.Servers
default:
return 0
}
}
// addLimit sets a metered limit to its configured total.
//
// The configured value is a TOTAL, not an increment, so this assigns rather than
// adds. A base of Unlimited is left alone: nothing can be added to no cap, and a
// plan that meters an already-unlimited dimension is a configuration mistake
// rather than something to compute around.
func addLimit(l *license.Limits, limitKey string, total int, base license.Limits) error {
switch limitKey {
case models.LimitKeyServers:
if base.MaxServers == license.Unlimited {
return nil
}
if total > base.MaxServers {
l.MaxServers = total
}
return nil
case "":
return fmt.Errorf("catalogue limit row has no limit_key")
default:
return fmt.Errorf("%w: limit_key %q", ErrUnknownPrice, limitKey)
}
}
+206
View File
@@ -0,0 +1,206 @@
package catalogue
import (
"context"
"fmt"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
)
// Item is one Paddle line item: a price and how many of it.
type Item struct {
PriceID string `json:"price_id"`
Quantity int `json:"quantity"`
}
// LineItems builds the subscription items for a configuration.
//
// The base row is always quantity 1. A metered row's quantity is the configured
// TOTAL minus the plan's base allowance, so a Professional customer at exactly
// three servers has a single-item subscription rather than one with a zero
// quantity Paddle would reject. A feature with no price in this environment
// produces no item and is granted free.
func LineItems(ctx context.Context, env, term string, plan *models.Plan, cfg models.Config) ([]Item, error) {
if !sells(plan.Deployment, term) {
return nil, fmt.Errorf("%w: %s does not sell %s", ErrTermNotSold, plan.Deployment, term)
}
rows, err := models.CatalogueFor(ctx, plan.Deployment, plan.Tier)
if err != nil {
return nil, err
}
if len(rows) == 0 {
return nil, fmt.Errorf("%w: %s/%s is priced by nothing",
ErrUnpriced, plan.Deployment, plan.Tier)
}
wanted := map[string]bool{}
for _, f := range cfg.Features {
wanted[f] = true
}
items := []Item{}
for _, r := range rows {
switch r.Kind {
case models.KindBase:
id := r.PriceID(env, term)
if id == "" {
return nil, fmt.Errorf("%w: base price for %s/%s in %s",
ErrUnpriced, plan.Deployment, plan.Tier, env)
}
items = append(items, Item{PriceID: id, Quantity: 1})
case models.KindLimit:
qty := billable(cfg, r.LimitKey, plan.BaseLimits)
if qty <= 0 {
continue
}
id := r.PriceID(env, term)
if id == "" {
return nil, fmt.Errorf("%w: %s price for %s/%s in %s",
ErrUnpriced, r.LimitKey, plan.Deployment, plan.Tier, env)
}
items = append(items, Item{PriceID: id, Quantity: qty})
case models.KindFeature:
if !wanted[r.FeatureKey] {
continue
}
id := r.PriceID(env, term)
if id == "" {
// Free to toggle. Resolve() still grants it.
continue
}
items = append(items, Item{PriceID: id, Quantity: 1})
}
}
return items, nil
}
// billable is how many UNITS to charge for a metered dimension.
//
// The configured value is the total the customer sees, which includes the base
// allowance they were given. Charging for that base is the single most likely
// bug in this file, so the subtraction lives here and nowhere else.
func billable(cfg models.Config, limitKey string, base license.Limits) int {
switch limitKey {
case models.LimitKeyServers:
if base.MaxServers == license.Unlimited {
return 0
}
return cfg.Servers - base.MaxServers
default:
return 0
}
}
// Match is what an item list says about itself.
type Match struct {
Deployment string
Tier string
Term string
Servers int
Features []string
}
// ResolveItems maps a full item list back to a plan and a configuration.
//
// This replaces a price-ID-to-tier lookup, which cannot work once a subscription
// has several prices. The base item identifies the plan and the term; everything
// else is read relative to it. An item matching nothing fails the whole list.
//
// Only the running environment's IDs are consulted, so a production process
// cannot be talked into resolving a sandbox price by a forged or misrouted
// event.
//
// It is a function of the COMPLETE list, which is what keeps out-of-order
// delivery correct by construction: Paddle sends every item on every
// subscription event, so reading all of them is reading current state rather
// than a transition.
func ResolveItems(ctx context.Context, env string, items []Item) (Match, error) {
all, err := models.AllCatalogue(ctx)
if err != nil {
return Match{}, err
}
// Pass 1: find the base item. Until we know the plan, no other item means
// anything — a quantity of 7 is 7 of what?
var m Match
found := false
for _, it := range items {
for _, r := range all {
if r.Kind != models.KindBase {
continue
}
for _, term := range []string{"monthly", "annual"} {
if r.PriceID(env, term) != it.PriceID || it.PriceID == "" {
continue
}
if found {
return Match{}, fmt.Errorf(
"item list names two plans: %s/%s and %s/%s",
m.Deployment, m.Tier, r.Deployment, r.Tier)
}
m.Deployment, m.Tier, m.Term = r.Deployment, r.Tier, term
found = true
}
}
}
if !found {
return Match{}, ErrNoBaseItem
}
if !sells(m.Deployment, m.Term) {
return Match{}, fmt.Errorf("%w: price resolves to %s %s; remove it from the catalogue",
ErrTermNotSold, m.Deployment, m.Term)
}
plan, err := models.GetPlan(ctx, m.Deployment, m.Tier)
if err != nil {
return Match{}, fmt.Errorf("item list names plan %s/%s, which does not exist: %w",
m.Deployment, m.Tier, err)
}
m.Servers = plan.BaseLimits.MaxServers
m.Features = []string{}
// Pass 2: everything else, relative to that plan. An item matching no row of
// this plan is a configuration error even if it matches some other plan's
// row — mixing two plans in one subscription is not a thing we sell.
rows, err := models.CatalogueFor(ctx, m.Deployment, m.Tier)
if err != nil {
return Match{}, err
}
for _, it := range items {
matched := false
for _, r := range rows {
if r.PriceID(env, m.Term) != it.PriceID {
continue
}
matched = true
switch r.Kind {
case models.KindBase:
// Already handled.
case models.KindLimit:
if r.LimitKey == models.LimitKeyServers {
m.Servers = plan.BaseLimits.MaxServers + it.Quantity
}
case models.KindFeature:
m.Features = append(m.Features, r.FeatureKey)
}
}
if !matched {
return Match{}, fmt.Errorf("%w: %s (environment %s, plan %s/%s)",
ErrUnknownPrice, it.PriceID, env, m.Deployment, m.Tier)
}
}
return m, nil
}
// sells reports whether a deployment offers a term.
func sells(deployment, term string) bool {
for _, t := range license.TermsFor(deployment) {
if t == term {
return true
}
}
return false
}
+43 -4
View File
@@ -15,9 +15,10 @@ import (
"context"
"fmt"
"github.com/mrhid6/vantage/admin/internal/db"
sharedmodels "github.com/mrhid6/vantage/shared/models"
"github.com/mrhid6/vantage/shared/provision"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/provision"
"github.com/google/uuid"
"go.mongodb.org/mongo-driver/v2/bson"
)
@@ -32,11 +33,29 @@ import (
// On owner-insert failure the instance is rolled back, so a failed provision
// never leaves a slug permanently occupied by an instance nobody owns.
func CreateInstance(ctx context.Context, name, ownerEmail, ownerPasswordHash, hqUserID string) (*sharedmodels.Instance, error) {
inst, err := provision.CreateInstance(ctx, db.ControlDB(), name)
return CreateInstanceWithID(ctx, uuid.NewString(), name, ownerEmail, ownerPasswordHash, hqUserID)
}
// CreateInstanceWithID provisions a cloud instance under a caller-supplied ID and
// its owner. It backs the paid-cloud flow, where the ID is a placeholder created
// before payment and provisioning runs on the confirmed-payment webhook (see
// provision.CreateInstanceWithID).
//
// It is idempotent, because a webhook can be retried after provisioning partly
// completed: the instance is created only if absent, and the owner only if the
// instance has none yet. A second call therefore converges to the same state
// rather than colliding on the per-instance email unique index.
func CreateInstanceWithID(ctx context.Context, instanceID, name, ownerEmail, ownerPasswordHash, hqUserID string) (*sharedmodels.Instance, error) {
inst, err := provision.CreateInstanceWithID(ctx, db.ControlDB(), instanceID, name)
if err != nil {
return nil, err
}
// A retry that already created the owner must not create a second one.
if _, err := OwnerUserID(ctx, inst.InstanceID); err == nil {
return inst, nil
}
u, err := provision.CreateUserWithHash(ctx, db.ControlDB(), inst.InstanceID,
ownerEmail, ownerPasswordHash, sharedmodels.RoleOwner, sharedmodels.AuthHQ)
if err != nil {
@@ -183,3 +202,23 @@ func ProjectedUsers(ctx context.Context, hqUserID string) ([]sharedmodels.User,
}
return users, nil
}
// RenameInstance changes a cloud instance's name and moves it to the slug that
// name derives to.
//
// It writes `instances` and nothing else, so admin's control-plane write
// boundary is unchanged. It issues no licence: a licence binds the instance
// UUID, which a rename never touches.
//
// The previous name and slug come back with the result because they are what an
// unwind must restore — admin's own copy can be stale, or slugless.
func RenameInstance(ctx context.Context, instanceID, name string) (inst *sharedmodels.Instance, prevName, prevSlug string, err error) {
return provision.RenameInstance(ctx, db.ControlDB(), instanceID, name)
}
// RestoreInstanceIdentity puts an instance's previous name and slug back, for a
// caller unwinding a rename whose admin-side write failed. Leaving the two
// databases disagreeing would have HQ print a host that is not the host.
func RestoreInstanceIdentity(ctx context.Context, instanceID, name, slug string) error {
return provision.RestoreInstanceIdentity(ctx, db.ControlDB(), instanceID, name, slug)
}
+12
View File
@@ -29,6 +29,10 @@ type Config struct {
Addr string
ReapAfter time.Duration
PaddleEnv string // "sandbox" or "production"
PaddleAPIKey string
PaddleWebhookSecret string
SMTPHost string
SMTPPort string
SMTPFrom string
@@ -75,6 +79,10 @@ func Load() (Config, error) {
SMTPFrom: os.Getenv("SMTP_FROM"),
SMTPUsername: os.Getenv("SMTP_USERNAME"),
SMTPPassword: os.Getenv("SMTP_PASSWORD"),
PaddleEnv: envOr("PADDLE_ENV", "sandbox"),
PaddleAPIKey: os.Getenv("PADDLE_API_KEY"),
PaddleWebhookSecret: os.Getenv("PADDLE_WEBHOOK_SECRET"),
}
var missing []string
@@ -85,6 +93,10 @@ func Load() (Config, error) {
"LICENSE_SIGNING_KEY": c.SigningKey,
"PUBLIC_URL": c.PublicURL,
"ADMIN_ORIGIN": os.Getenv("ADMIN_ORIGIN"),
// An unverified webhook endpoint is one anyone can issue licences
// through, so the secret and API key are boot-required.
"PADDLE_API_KEY": c.PaddleAPIKey,
"PADDLE_WEBHOOK_SECRET": c.PaddleWebhookSecret,
} {
if v == "" {
missing = append(missing, name)
+31 -2
View File
@@ -11,9 +11,10 @@ package db
import (
"context"
"fmt"
"log"
"time"
"github.com/mrhid6/vantage/admin/internal/config"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/config"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
@@ -84,7 +85,7 @@ func EnsureIndexes(ctx context.Context) error {
{"accounts", "account_id"},
{"admin_instances", "instance_id"},
{"licenses", "license_id"},
{"plans", "tier"},
{"paddle_events", "event_id"},
{"staff_users", "email"},
{"customer_users", "email"},
}
@@ -106,6 +107,34 @@ func EnsureIndexes(ctx context.Context) error {
return fmt.Errorf("index subscriptions.paddle_subscription_id: %w", err)
}
// plans was unique on tier alone until spec 7. Mongo will not replace an
// index implicitly, and the old one would refuse the second row of every
// tier, so it is dropped by name here. Dropping a missing index is not an
// error worth failing boot over — a fresh database has never had it.
if err := Admin("plans").Indexes().DropOne(ctx, "tier_unique"); err != nil {
log.Printf("index plans.tier_unique: not dropped (%v); expected on a fresh database", err)
}
for _, u := range []struct {
coll string
keys bson.D
name string
}{
{"plans", bson.D{{Key: "deployment", Value: 1}, {Key: "tier", Value: 1}}, "deployment_tier_unique"},
{"catalogue", bson.D{
{Key: "deployment", Value: 1}, {Key: "tier", Value: 1}, {Key: "kind", Value: 1},
{Key: "limit_key", Value: 1}, {Key: "feature_key", Value: 1},
}, "component_unique"},
{"entitlements", bson.D{{Key: "instance_id", Value: 1}}, "instance_id_unique"},
} {
if _, err := Admin(u.coll).Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: u.keys,
Options: options.Index().SetUnique(true).SetName(u.name),
}); err != nil {
return fmt.Errorf("index %s.%s: %w", u.coll, u.name, err)
}
}
for _, idx := range []struct {
coll string
keys bson.D
+3 -3
View File
@@ -13,9 +13,9 @@ import (
"log"
"time"
"github.com/mrhid6/vantage/admin/internal/cloudprov"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/cloudprov"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
)
+4 -4
View File
@@ -12,10 +12,10 @@ import (
"log"
"time"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/models"
"github.com/mrhid6/vantage/shared/license"
sharedmodels "github.com/mrhid6/vantage/shared/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
+51 -15
View File
@@ -5,20 +5,22 @@ import (
"context"
"errors"
"fmt"
"log"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/catalogue"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"github.com/google/uuid"
"github.com/mrhid6/vantage/admin/internal/audit"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/models"
"github.com/mrhid6/vantage/shared/license"
"go.mongodb.org/mongo-driver/v2/bson"
)
var (
ErrUnknownTier = errors.New("unknown tier")
ErrDeploymentMismatch = errors.New("that plan is not available for this deployment type")
ErrFreeLimit = errors.New("this account already has a Free instance")
ErrFreeLimit = errors.New("this account already has a Free instance of that deployment type")
ErrUnknownInstance = errors.New("instance not found")
)
@@ -54,7 +56,10 @@ func Issue(ctx context.Context, in IssueInput) (*models.License, error) {
return nil, ErrUnknownInstance
}
plan, err := models.GetPlan(ctx, in.Tier)
// The plan is looked up by the INSTANCE's deployment, not by a caller's
// guess. That is what makes the deployment comparison below a consistency
// check rather than the thing that decides which plan applies.
plan, err := models.GetPlan(ctx, inst.Deployment, in.Tier)
if err != nil {
return nil, ErrUnknownTier
}
@@ -67,11 +72,35 @@ func Issue(ctx context.Context, in IssueInput) (*models.License, error) {
}
if plan.Tier == license.TierFree {
if err := checkFreeLimit(ctx, inst.AccountID, inst.InstanceID); err != nil {
if err := checkFreeLimit(ctx, inst.AccountID, inst.Deployment, inst.InstanceID); err != nil {
return nil, err
}
}
// What this licence grants comes from the instance's entitlement, not from
// the plan. The plan is only the base.
//
// An instance with no entitlement gets the plan's base, which covers staff
// manual issuance and anything predating the backfill. Falling back is
// deliberate: refusing here would make a missing row an outage rather than a
// default.
limits, features := plan.BaseLimits, []string(plan.BaseFeatures.OrEmpty())
ent, entErr := models.GetEntitlement(ctx, inst.InstanceID)
switch {
case entErr == nil:
// Granted, never Desired. A configuration nobody has paid for must not
// reach a signed payload.
limits, features, err = catalogue.Resolve(ctx, plan, ent.Granted)
if err != nil {
return nil, fmt.Errorf("resolve entitlement: %w", err)
}
case errors.Is(entErr, models.ErrNoEntitlement):
log.Printf("licensing: instance %s has no entitlement; issuing plan base",
inst.InstanceID)
default:
return nil, fmt.Errorf("read entitlement: %w", entErr)
}
now := time.Now().UTC()
expires := in.ExpiresAt
if expires.IsZero() {
@@ -94,10 +123,11 @@ func Issue(ctx context.Context, in IssueInput) (*models.License, error) {
Deployment: plan.Deployment,
IssuedAt: now,
ExpiresAt: expires,
// Snapshotted, not referenced: editing a plan tomorrow must not change
// what this licence grants.
Limits: plan.Limits,
Features: plan.Features,
// Snapshotted, not referenced: editing a plan or an entitlement tomorrow
// must not change what this licence grants.
Limits: limits,
Features: features,
SupportLevel: plan.SupportLevel,
}
blob, err := license.Sign(payload, signingKey)
@@ -111,8 +141,8 @@ func Issue(ctx context.Context, in IssueInput) (*models.License, error) {
AccountID: inst.AccountID,
Tier: plan.Tier,
Deployment: plan.Deployment,
Limits: plan.Limits,
Features: plan.Features,
Limits: limits,
Features: models.Features(features).OrEmpty(),
IssuedAt: now,
ExpiresAt: expires,
Blob: blob,
@@ -157,13 +187,19 @@ func Issue(ctx context.Context, in IssueInput) (*models.License, error) {
return &rec, nil
}
// checkFreeLimit enforces one Free instance per account.
// checkFreeLimit enforces one Free instance per account PER DEPLOYMENT.
//
// It used to be one per account, which was sufficient while Free existed only on
// cloud. With a self-hosted Free plan, an account-wide count would refuse a
// self-hosted Free instance to anyone holding a cloud one, and tell them about a
// limit they have not reached.
//
// Cancelled instances do not count: a customer who cancelled their Free instance
// is allowed another one.
func checkFreeLimit(ctx context.Context, accountID, exceptInstanceID string) error {
func checkFreeLimit(ctx context.Context, accountID, deployment, exceptInstanceID string) error {
n, err := db.Admin("admin_instances").CountDocuments(ctx, bson.M{
"account_id": accountID,
"deployment": deployment,
"tier": license.TierFree,
"status": bson.M{"$ne": models.StatusCancelled},
"instance_id": bson.M{"$ne": exceptInstanceID},
+61 -5
View File
@@ -4,13 +4,15 @@ import (
"context"
"errors"
"fmt"
"log"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/paddle"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"github.com/google/uuid"
"github.com/mrhid6/vantage/admin/internal/audit"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/models"
"github.com/mrhid6/vantage/shared/license"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
@@ -57,6 +59,48 @@ func LinkInstance(ctx context.Context, accountID, instanceID, name string) (*mod
return &inst, nil
}
// RepointSubscriptions follows an instance identity rewrite: it moves every
// subscription row from the old id to the new one, then rewrites Paddle's copy
// of custom_data so future webhooks decode to the new id.
//
// The local rewrite is returned as an error — issuance reads the subscription
// back, so a half-moved row is worth failing on. The Paddle patch only logs: the
// customer must not be blocked from relinking by an outbound API
// failure, and the caller has already recorded the old id in
// previous_instance_ids, which is what makes the webhook path correct whether or
// not the patch lands.
func RepointSubscriptions(ctx context.Context, oldID, newID, accountID string) error {
if _, err := db.Admin("subscriptions").UpdateMany(ctx,
bson.M{"instance_id": oldID},
bson.M{"$set": bson.M{"instance_id": newID}}); err != nil {
return fmt.Errorf("repoint %s -> %s: %w", oldID, newID, err)
}
cur, err := db.Admin("subscriptions").Find(ctx, bson.M{"instance_id": newID})
if err != nil {
log.Printf("repoint %s -> %s: read subscriptions: %v", oldID, newID, err)
return nil
}
var subs []models.Subscription
if err := cur.All(ctx, &subs); err != nil {
log.Printf("repoint %s -> %s: decode subscriptions: %v", oldID, newID, err)
return nil
}
for _, s := range subs {
if s.PaddleSubscriptionID == "" {
continue
}
// Paddle replaces the whole custom_data object on a PATCH, so account_id
// is sent alongside rather than dropped.
if err := paddle.Get().UpdateSubscriptionCustomData(ctx, s.PaddleSubscriptionID,
map[string]string{"account_id": accountID, "instance_id": newID}); err != nil {
log.Printf("repoint %s -> %s: patch custom_data on %s: %v",
oldID, newID, s.PaddleSubscriptionID, err)
}
}
return nil
}
// Relink moves a licence to a rebuilt server's new UUID.
//
// The replacement covers the REMAINING term, not a fresh one — relinking is not
@@ -96,13 +140,25 @@ func Relink(ctx context.Context, accountID, oldID, newID string, staff bool) (*m
if _, err := db.Admin("admin_instances").UpdateOne(ctx,
bson.M{"instance_id": oldID},
bson.M{"$set": bson.M{"instance_id": newID}, "$inc": bson.M{"relink_count": 1}}); err != nil {
bson.M{
"$set": bson.M{"instance_id": newID},
"$inc": bson.M{"relink_count": 1},
"$addToSet": bson.M{"previous_instance_ids": oldID},
}); err != nil {
if mongo.IsDuplicateKeyError(err) {
return nil, ErrAlreadyLinked
}
return nil, fmt.Errorf("relink: %w", err)
}
// A relink rewrites the instance's identity, so two things have to follow it:
// the subscription rows that named the old id, and Paddle's own copy of
// custom_data. Without this a
// renewal after a relink cannot find its instance and the term never extends.
if err := RepointSubscriptions(ctx, oldID, newID, accountID); err != nil {
return nil, err
}
actor := accountID
if staff {
actor = "staff"
+27 -8
View File
@@ -11,10 +11,10 @@ import (
"slices"
"time"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/mail"
"github.com/mrhid6/vantage/admin/internal/models"
"github.com/mrhid6/vantage/shared/license"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/mail"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"go.mongodb.org/mongo-driver/v2/bson"
)
@@ -77,6 +77,16 @@ func Run(ctx context.Context) error {
}
}
if lic.ExpiresAt.Add(reapAfter).Before(now) {
if _, err := db.Admin("admin_instances").DeleteOne(ctx, bson.M{"instance_id": inst.InstanceID}); err != nil {
log.Printf("lifecycle: delete instance %s: %v", inst.InstanceID, err)
}
if _, err := db.Admin("licenses").DeleteMany(ctx, bson.M{"instance_id": inst.InstanceID}); err != nil {
log.Printf("lifecycle: delete licenses for instance %s: %v", inst.InstanceID, err)
}
}
due := dueNotice(now, lic.ExpiresAt, inst.NoticesSent)
if due == "" {
continue
@@ -133,13 +143,13 @@ func sendNotice(ctx context.Context, inst models.Instance, lic models.License, k
switch key {
case noticeExpiring:
return mail.SendExpiring(to, inst.Name, portalURL, lic.ExpiresAt)
return mail.Default.SendExpiring(to, inst.Name, portalURL, lic.ExpiresAt)
case noticeExpired:
return mail.SendExpired(to, inst.Name, portalURL, deleteOn)
return mail.Default.SendExpired(to, inst.Name, portalURL, deleteOn)
case noticeDelete7:
return mail.SendDeletionWarning(to, inst.Name, portalURL, deleteOn, 7)
return mail.Default.SendDeletionWarning(to, inst.Name, portalURL, deleteOn, 7)
case noticeDelete1:
return mail.SendDeletionWarning(to, inst.Name, portalURL, deleteOn, 1)
return mail.Default.SendDeletionWarning(to, inst.Name, portalURL, deleteOn, 1)
}
return nil
}
@@ -173,3 +183,12 @@ func runOnce(ctx context.Context) {
log.Printf("lifecycle: %v", err)
}
}
func accountEmail(ctx context.Context, accountID string) string {
var acct models.Account
if err := db.Admin("accounts").FindOne(ctx,
bson.M{"account_id": accountID}).Decode(&acct); err != nil {
return ""
}
return acct.BillingEmail
}
+14 -221
View File
@@ -1,226 +1,19 @@
// Package mail delivers verification links and licence files.
// Package mail holds admin's configured email sender.
//
// The transport, the templates and the look all live in shared/mail, which the
// control plane and sitesvc use too — this package exists only so that admin's
// mail configuration is a boot-time singleton like licensing's signing key,
// paddle's client and auth's Redis handle, rather than a value threaded through
// api, auth, billing and lifecycle.
package mail
import (
"crypto/rand"
"crypto/tls"
"encoding/hex"
"fmt"
"mime"
"net"
"net/smtp"
"strings"
"time"
)
import "gitea.hostxtra.co.uk/mrhid6/vantage/shared/mail"
// timeout bounds the whole SMTP conversation. Without it a mail server that
// accepts the connection and then stalls holds an HTTP request open until the
// client gives up — and signup's rollback runs on that request's context.
const timeout = 15 * time.Second
// Default is admin's sender. Set once by main; read everywhere else.
var Default mail.Sender
type Config struct {
Host, Port, From, Username, Password string
PublicURL string
}
func Init(s mail.Sender) { Default = s }
var cfg Config
func Init(c Config) { cfg = c }
func Enabled() bool { return cfg.Host != "" && cfg.From != "" }
// send delivers one message.
//
// Port 465 is implicit TLS: the server expects a TLS handshake immediately, so
// the connection is wrapped BEFORE any SMTP is spoken. Every other port gets
// plaintext then STARTTLS if offered. `net/smtp.SendMail` only does the latter,
// which is why it fails against a 465 mail server — this exact bug silently
// stopped every admin email from being delivered.
//
// This mirrors sitesvc/internal/mail, which solved the same problem first. The
// two are duplicated rather than shared; if you change the transport here,
// change it there too, or consolidate both into shared/.
func send(to, subject, body string) error {
if !Enabled() {
return fmt.Errorf("SMTP is not configured")
}
if strings.TrimSpace(to) == "" {
return fmt.Errorf("smtp: no recipient")
}
addr := net.JoinHostPort(cfg.Host, cfg.Port)
conn, err := net.DialTimeout("tcp", addr, timeout)
if err != nil {
return fmt.Errorf("smtp: dial %s: %w", addr, err)
}
_ = conn.SetDeadline(time.Now().Add(timeout))
if cfg.Port == "465" {
conn = tls.Client(conn, &tls.Config{ServerName: cfg.Host})
}
client, err := smtp.NewClient(conn, cfg.Host)
if err != nil {
conn.Close()
return fmt.Errorf("smtp: client: %w", err)
}
defer client.Close()
if cfg.Port != "465" {
if ok, _ := client.Extension("STARTTLS"); ok {
if err := client.StartTLS(&tls.Config{ServerName: cfg.Host}); err != nil {
return fmt.Errorf("smtp: starttls: %w", err)
}
}
}
if cfg.Username != "" {
if err := client.Auth(smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host)); err != nil {
return fmt.Errorf("smtp: auth: %w", err)
}
}
if err := client.Mail(cfg.From); err != nil {
return fmt.Errorf("smtp: mail from: %w", err)
}
if err := client.Rcpt(to); err != nil {
return fmt.Errorf("smtp: rcpt %s: %w", to, err)
}
w, err := client.Data()
if err != nil {
return fmt.Errorf("smtp: data: %w", err)
}
if _, err := w.Write(message(to, subject, body)); err != nil {
return fmt.Errorf("smtp: write: %w", err)
}
if err := w.Close(); err != nil {
return fmt.Errorf("smtp: close data: %w", err)
}
return client.Quit()
}
// message builds the RFC 5322 envelope.
//
// Date and Message-ID are not decoration: a message without them is scored as
// spam by most filters, which is its own way of "the email never arrived".
// Header values are stripped of CR and LF so a crafted instance name cannot
// inject extra headers.
func message(to, subject, body string) []byte {
var b strings.Builder
b.WriteString("From: " + sanitizeHeader(cfg.From) + "\r\n")
b.WriteString("To: " + sanitizeHeader(to) + "\r\n")
b.WriteString("Date: " + time.Now().Format(time.RFC1123Z) + "\r\n")
b.WriteString("Message-ID: " + messageID(cfg.From) + "\r\n")
b.WriteString("Subject: " + mime.QEncoding.Encode("utf-8", sanitizeHeader(subject)) + "\r\n")
b.WriteString("MIME-Version: 1.0\r\n")
b.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
b.WriteString("\r\n")
b.WriteString(body)
return []byte(b.String())
}
func messageID(from string) string {
domain := "vantage.local"
if at := strings.LastIndex(from, "@"); at >= 0 && at < len(from)-1 {
domain = strings.Trim(from[at+1:], "<> ")
}
var buf [16]byte
if _, err := rand.Read(buf[:]); err != nil {
return fmt.Sprintf("<%d@%s>", time.Now().UnixNano(), domain)
}
return fmt.Sprintf("<%s@%s>", hex.EncodeToString(buf[:]), domain)
}
func sanitizeHeader(v string) string {
return strings.NewReplacer("\r", " ", "\n", " ").Replace(v)
}
func SendVerification(to, token string) error {
link := fmt.Sprintf("%s/verify?token=%s", cfg.PublicURL, token)
return send(to, "Verify your Vantage account",
"Confirm your email address to finish setting up your Vantage account:\n\n"+
link+"\n\nThis link expires in 24 hours.\n")
}
// SendInvite asks someone to join an existing account and set their own
// password. It names the account, because an unexpected invitation from a
// service you have never used is otherwise indistinguishable from spam.
func SendInvite(to, accountName, token string) error {
link := fmt.Sprintf("%s/accept-invite?token=%s", cfg.PublicURL, token)
return send(to, "You have been invited to "+sanitizeHeader(accountName)+" on Vantage",
fmt.Sprintf("You have been invited to join %s on Vantage.\n\n"+
"Set your password and finish joining:\n\n%s\n\n"+
"This link expires in 24 hours. If you were not expecting this, ignore it — "+
"nothing happens until you open the link.\n", accountName, link))
}
// SendLicense delivers the blob inline. It is signed public data, not a secret —
// it is useless on any instance other than the one it names.
func SendLicense(to, instanceName, blob string) error {
return send(to, "Your Vantage licence key",
fmt.Sprintf("Your licence for %s is below.\n\n"+
"Paste it into Settings → Licence on your Vantage install:\n\n%s\n",
instanceName, blob))
}
// SendInstanceReady tells a customer their cloud instance exists, where it is,
// and when its licence runs out.
//
// The expiry is stated here rather than only in a later reminder: a Free licence
// that quietly expires in a month is a surprise, and the first email is the one
// people keep.
func SendInstanceReady(to, instanceName, loginURL string, expires time.Time) error {
body := fmt.Sprintf("%s is ready.\n\n", instanceName)
if loginURL != "" {
body += "Sign in here:\n\n" + loginURL + "\n\n"
}
body += fmt.Sprintf(
"Your Free licence runs until %s. We will email you before then so you can renew it in one click.\n\n"+
"Sign in with the same email address and password you use for your Vantage account. "+
"Changing your Vantage HQ password changes it here too.\n",
expires.Format("2 January 2006"))
return send(to, instanceName+" is ready", body)
}
// SendRenewed confirms a renewal and states the new date.
func SendRenewed(to, instanceName string, expires time.Time) error {
return send(to, instanceName+" renewed",
fmt.Sprintf("%s is renewed.\n\nYour Free licence now runs until %s.\n",
instanceName, expires.Format("2 January 2006")))
}
// SendExpiring is the renew-now nudge, seven days out.
func SendExpiring(to, instanceName, portalURL string, expires time.Time) error {
return send(to, instanceName+" expires on "+expires.Format("2 January"),
fmt.Sprintf("%s's Free licence runs out on %s.\n\n"+
"Renew it in one click:\n\n%s\n\n"+
"If you do nothing, the instance keeps running but stops accepting changes.\n",
instanceName, expires.Format("2 January 2006"), portalURL))
}
// SendExpired states plainly what has stopped and what happens next.
//
// It names the deletion date rather than a vague warning: the whole point of the
// sequence is that nobody loses an instance without having been told a date.
func SendExpired(to, instanceName, portalURL string, deleteOn time.Time) error {
return send(to, instanceName+" is now read-only",
fmt.Sprintf("%s's Free licence has expired.\n\n"+
"Your servers and monitors keep running and your agents keep their keys, "+
"but changes are disabled.\n\n"+
"Renew it here:\n\n%s\n\n"+
"If it is not renewed, the instance and everything in it will be deleted on %s.\n",
instanceName, portalURL, deleteOn.Format("2 January 2006")))
}
// SendDeletionWarning is the final countdown, sent at seven days and one day.
func SendDeletionWarning(to, instanceName, portalURL string, deleteOn time.Time, daysLeft int) error {
when := fmt.Sprintf("in %d days", daysLeft)
if daysLeft <= 1 {
when = "tomorrow"
}
return send(to, instanceName+" will be deleted "+when,
fmt.Sprintf("%s and everything in it will be deleted %s, on %s.\n\n"+
"This cannot be undone. Renew it here to keep it:\n\n%s\n",
instanceName, when, deleteOn.Format("2 January 2006"), portalURL))
}
// Enabled reports whether SMTP is configured. Callers check it to skip a send
// politely rather than logging a failure per message.
func Enabled() bool { return Default.Enabled() }
+271 -3
View File
@@ -2,17 +2,107 @@ package models
import (
"context"
"errors"
"log"
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
"github.com/google/uuid"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/shared/license"
sharedmodels "github.com/mrhid6/vantage/shared/models"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
// MigrateLegacyPlans re-keys the pre-spec-7 plan rows and MUST run before
// SeedPlans.
//
// The original three rows were keyed on tier alone: (cloud,free),
// (cloud,professional) and a self_hosted TIER row. Spec 7 re-keys on
// (deployment, tier), makes self_hosted a deployment rather than a tier, and
// renames limits/features to base_limits/base_features.
//
// Ordering is the whole point. SeedPlans inserts a fresh (self_hosted,
// professional) row; if the legacy self_hosted row is only renamed afterwards it
// collides with that seed on deployment_tier_unique. Running here, before the
// seed, the rename lands first and the seed then no-ops on it.
//
// It is idempotent and self-healing: on a database where a previous boot already
// seeded (self_hosted, professional) before crashing, the legacy row can no
// longer be renamed onto it, so the legacy row is dropped instead — its
// instances are re-tiered and re-entitled from the surviving professional row.
func MigrateLegacyPlans(ctx context.Context) error {
// Legacy cloud rows may predate the deployment field entirely.
if _, err := db.Admin("plans").UpdateMany(ctx,
bson.M{"deployment": bson.M{"$exists": false},
"tier": bson.M{"$in": bson.A{license.TierFree, license.TierProfessional}}},
bson.M{"$set": bson.M{"deployment": license.DeploymentCloud}}); err != nil {
return err
}
// The legacy self_hosted TIER row becomes self-hosted Professional. If that
// target already exists (a prior partial boot seeded it), drop the legacy row
// rather than colliding — the seeded row carries the same professional base.
var legacy Plan
err := db.Admin("plans").FindOne(ctx, bson.M{"tier": license.TierSelfHosted}).Decode(&legacy)
switch {
case err == nil:
targetErr := db.Admin("plans").FindOne(ctx,
bson.M{"deployment": license.DeploymentSelfHosted, "tier": license.TierProfessional}).Err()
if targetErr == nil {
if _, err := db.Admin("plans").DeleteOne(ctx, bson.M{"_id": legacy.ID}); err != nil {
return err
}
log.Printf("backfill: dropped legacy self_hosted plan row; (self_hosted, professional) already present")
} else if errors.Is(targetErr, mongo.ErrNoDocuments) {
if _, err := db.Admin("plans").UpdateOne(ctx,
bson.M{"_id": legacy.ID},
bson.M{"$set": bson.M{
"deployment": license.DeploymentSelfHosted,
"tier": license.TierProfessional,
"name": "Professional",
}}); err != nil {
return err
}
log.Printf("backfill: re-keyed legacy self_hosted plan to (self_hosted, professional)")
} else {
return targetErr
}
case errors.Is(err, mongo.ErrNoDocuments):
// No legacy row; a fresh database or an already-migrated one.
default:
return err
}
// limits/features become base_limits/base_features on any row still carrying
// the old names.
if _, err := db.Admin("plans").UpdateMany(ctx,
bson.M{"limits": bson.M{"$exists": true}},
bson.M{"$rename": bson.M{"limits": "base_limits", "features": "base_features"}}); err != nil {
return err
}
// Support level is new, so nothing has one. Fill from the seed table rather
// than guessing: a plan row a human edited keeps every other field.
for _, deployment := range license.Deployments() {
for _, tier := range license.Tiers() {
p, ok := license.PlanFor(deployment, tier)
if !ok {
continue
}
if _, err := db.Admin("plans").UpdateOne(ctx,
bson.M{"deployment": deployment, "tier": tier,
"support_level": bson.M{"$in": bson.A{nil, ""}}},
bson.M{"$set": bson.M{"support_level": p.SupportLevel}}); err != nil {
return err
}
}
}
return nil
}
// Backfill brings pre-phase-3 data up to the membership model.
//
// It runs on every boot and is idempotent by construction: both passes filter
@@ -97,5 +187,183 @@ func Backfill(ctx context.Context) error {
if created > 0 {
log.Printf("backfill: recorded %d pre-existing instance owners", created)
}
// Pass 3 (the plan re-key) now runs in MigrateLegacyPlans, called from main
// BEFORE SeedPlans. It has to: SeedPlans inserts a fresh
// (self_hosted, professional) row, and if the legacy self_hosted TIER row is
// only renamed afterwards it collides with that seed on deployment_tier_unique.
// Pass 4: instances carrying the self_hosted TIER move to Professional.
// Their deployment already says self_hosted, so only the tier is wrong.
res, err = db.Admin("admin_instances").UpdateMany(ctx,
bson.M{"tier": license.TierSelfHosted},
bson.M{"$set": bson.M{"tier": license.TierProfessional}})
if err != nil {
return err
}
if res.ModifiedCount > 0 {
log.Printf("backfill: re-tiered %d self-hosted instances to professional", res.ModifiedCount)
}
// Pass 5: give every instance an entitlement, reconstructed from its current
// licence. Filtering on the absence of a row is what makes this idempotent,
// and it means an entitlement a customer has since edited is never
// overwritten by a stale licence.
if err := backfillEntitlements(ctx); err != nil {
return err
}
// Pass 6: instances whose identity was rewritten before previous_instance_ids
// existed carry no trail, and Paddle's custom_data still names the id they
// were rewritten FROM — so their next webhook resolves to nothing. Both
// rewrites wrote an audit entry naming the old id, which is the only surviving
// record of it, so reconstruct the trail from those.
if err := backfillInstanceIDHistory(ctx); err != nil {
return err
}
return nil
}
// backfillInstanceIDHistory rebuilds previous_instance_ids from the audit entries
// the two identity rewrites leave behind: a placeholder claim
// ("instance.placeholder_linked", detail "from placeholder <id>") and a relink
// ("instance.relinked", detail "was <id>").
//
// $addToSet is what makes it idempotent, and it also means a chain of relinks
// accumulates rather than the last one winning. Entries are walked NEWEST first,
// matching on the current id or an already-recovered one: an instance relinked
// A→B→C answers to neither A nor B by the time this runs, so the C entry has to
// record B before the B entry has anything to attach A to.
func backfillInstanceIDHistory(ctx context.Context) error {
prefixes := map[string]string{
"instance.placeholder_linked": "from placeholder ",
"instance.relinked": "was ",
}
actions := make(bson.A, 0, len(prefixes))
for action := range prefixes {
actions = append(actions, action)
}
cur, err := db.Admin("admin_audit").Find(ctx,
bson.M{"action": bson.M{"$in": actions}},
options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}}))
if err != nil {
return err
}
var entries []AuditEntry
if err := cur.All(ctx, &entries); err != nil {
return err
}
recorded := 0
for _, e := range entries {
prefix := prefixes[e.Action]
if e.Target == "" || !strings.HasPrefix(e.Detail, prefix) {
continue
}
oldID := strings.TrimSpace(strings.TrimPrefix(e.Detail, prefix))
if oldID == "" || oldID == e.Target {
continue
}
res, err := db.Admin("admin_instances").UpdateOne(ctx,
bson.M{"$or": bson.A{
bson.M{"instance_id": e.Target},
bson.M{"previous_instance_ids": e.Target},
}},
bson.M{"$addToSet": bson.M{"previous_instance_ids": oldID}})
if err != nil {
return err
}
recorded += int(res.ModifiedCount)
}
if recorded > 0 {
log.Printf("backfill: recovered %d instance id rewrites from the audit log", recorded)
}
return nil
}
// backfillEntitlements reconstructs an entitlement per instance from its licence.
//
// An Unlimited max_servers maps back to the plan's BASE allowance rather than to
// a huge number: an unlimited licence bought no server units, so the honest
// reconstruction of "how many did they pay for" is none. This makes a
// pre-metering Professional instance read as 3 servers, which is a REDUCTION in
// what it is allowed. That is deliberate and it is why this is a plan step and
// not a silent fix — see the task's confirmation step.
func backfillEntitlements(ctx context.Context) error {
cur, err := db.Admin("admin_instances").Find(ctx,
bson.M{"status": bson.M{"$ne": StatusDeleted}})
if err != nil {
return err
}
var instances []Instance
if err := cur.All(ctx, &instances); err != nil {
return err
}
created := 0
for _, inst := range instances {
n, err := db.Admin("entitlements").CountDocuments(ctx,
bson.M{"instance_id": inst.InstanceID})
if err != nil {
return err
}
if n > 0 {
continue
}
deployment, tier := license.NormaliseTier(inst.Deployment, inst.Tier)
if tier == "" {
// An instance awaiting its first licence has no tier. It gets an
// entitlement when one is issued, not before.
continue
}
plan, err := GetPlan(ctx, deployment, tier)
if err != nil {
log.Printf("backfill: instance %s names unknown plan %s/%s; skipped",
inst.InstanceID, deployment, tier)
continue
}
cfg := Config{Servers: plan.BaseLimits.MaxServers, Features: Features{}}
if inst.CurrentLicense != "" {
var lic License
if err := db.Admin("licenses").FindOne(ctx,
bson.M{"license_id": inst.CurrentLicense}).Decode(&lic); err == nil {
if lic.Limits.MaxServers != license.Unlimited && lic.Limits.MaxServers > 0 {
cfg.Servers = lic.Limits.MaxServers
}
cfg.Features = lic.Features.OrEmpty()
}
}
limits := plan.BaseLimits
limits.MaxServers = cfg.Servers
if err := UpsertEntitlement(ctx, Entitlement{
InstanceID: inst.InstanceID,
AccountID: inst.AccountID,
Deployment: deployment,
Tier: tier,
Term: defaultTerm(deployment),
Desired: cfg,
Granted: cfg,
ResolvedLimits: limits,
}); err != nil {
return err
}
created++
}
if created > 0 {
log.Printf("backfill: created %d entitlements from current licences", created)
}
return nil
}
// defaultTerm is the term to assume for a reconstructed entitlement. Self-hosted
// sells annual only, so there is nothing to guess there.
func defaultTerm(deployment string) string {
if deployment == license.DeploymentSelfHosted {
return "annual"
}
return "monthly"
}
+142
View File
@@ -0,0 +1,142 @@
package models
import (
"context"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
// Component kinds.
const (
// KindBase is the plan's own fee, always quantity 1.
KindBase = "base"
// KindLimit raises a named limit by one per unit of quantity.
KindLimit = "limit"
// KindFeature is an on/off feature key.
KindFeature = "feature"
)
// LimitKeyServers is the only metered limit today.
//
// A limit_key is a field name in license.Limits, which is what lets a second
// metered dimension be a catalogue row rather than a code change. There is
// deliberately no block size: with secret-group blocks dropped from the spec it
// would be 1 in every row that will ever exist.
const LimitKeyServers = "max_servers"
// CatalogueRow is one priceable component of one plan.
//
// This is the ONLY place a Paddle price ID appears anywhere in Vantage. An empty
// PriceIDs means the component is free — a feature with no price is a toggle a
// customer may take at no charge, and giving it a price later is a staff edit
// rather than a migration or a deploy.
type CatalogueRow struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
Kind string `bson:"kind" json:"kind"`
Deployment string `bson:"deployment" json:"deployment"`
Tier string `bson:"tier" json:"tier"`
LimitKey string `bson:"limit_key,omitempty" json:"limit_key,omitempty"`
FeatureKey string `bson:"feature_key,omitempty" json:"feature_key,omitempty"`
// PriceIDs is environment -> term -> Paddle price ID, e.g.
// {"sandbox": {"monthly": "pri_…"}, "production": {"annual": "pri_…"}}.
//
// Nested by environment rather than kept in two collections, because
// promoting sandbox to production must be a configuration change and not a
// data migration. The running PADDLE_ENV picks the inner map.
PriceIDs map[string]map[string]string `bson:"price_ids,omitempty" json:"price_ids,omitempty"`
}
// PriceID returns the price for one environment and term, or "".
func (r CatalogueRow) PriceID(env, term string) string {
if r.PriceIDs == nil {
return ""
}
return r.PriceIDs[env][term]
}
// Priced reports whether this component costs anything in an environment.
func (r CatalogueRow) Priced(env string) bool {
for _, term := range []string{"monthly", "annual"} {
if r.PriceID(env, term) != "" {
return true
}
}
return false
}
// SeedCatalogue inserts the twenty rows the four PAID plans need: a base, a
// server limit, and one row per feature key.
//
// The two Free plans get no rows at all, and that absence is what keeps Free
// outside Paddle: with nothing to price, no checkout can be built for it. Do not
// "fix" this by adding zero-priced Free rows.
//
// $setOnInsert only, for the same reason as SeedPlans: the price IDs are pasted
// in by staff and a redeploy must not blank them.
func SeedCatalogue(ctx context.Context) error {
paid := []string{license.TierProfessional, license.TierEnterprise}
for _, deployment := range license.Deployments() {
for _, tier := range paid {
rows := []CatalogueRow{
{Kind: KindBase, Deployment: deployment, Tier: tier},
{Kind: KindLimit, Deployment: deployment, Tier: tier, LimitKey: LimitKeyServers},
{Kind: KindFeature, Deployment: deployment, Tier: tier, FeatureKey: license.FeatureConsole},
{Kind: KindFeature, Deployment: deployment, Tier: tier, FeatureKey: license.FeatureOIDC},
{Kind: KindFeature, Deployment: deployment, Tier: tier, FeatureKey: license.FeatureVulnScanning},
}
for _, r := range rows {
filter := bson.M{
"kind": r.Kind,
"deployment": r.Deployment,
"tier": r.Tier,
"limit_key": r.LimitKey,
"feature_key": r.FeatureKey,
}
if _, err := db.Admin("catalogue").UpdateOne(ctx, filter,
bson.M{"$setOnInsert": bson.M{
"kind": r.Kind,
"deployment": r.Deployment,
"tier": r.Tier,
"limit_key": r.LimitKey,
"feature_key": r.FeatureKey,
"price_ids": map[string]map[string]string{},
}},
options.UpdateOne().SetUpsert(true)); err != nil {
return err
}
}
}
}
return nil
}
// CatalogueFor returns every component of one plan.
func CatalogueFor(ctx context.Context, deployment, tier string) ([]CatalogueRow, error) {
deployment, tier = license.NormaliseTier(deployment, tier)
cur, err := db.Admin("catalogue").Find(ctx,
bson.M{"deployment": deployment, "tier": tier})
if err != nil {
return nil, err
}
rows := []CatalogueRow{}
if err := cur.All(ctx, &rows); err != nil {
return nil, err
}
return rows, nil
}
// AllCatalogue returns every row, for the staff editor.
func AllCatalogue(ctx context.Context) ([]CatalogueRow, error) {
cur, err := db.Admin("catalogue").Find(ctx, bson.M{})
if err != nil {
return nil, err
}
rows := []CatalogueRow{}
if err := cur.All(ctx, &rows); err != nil {
return nil, err
}
return rows, nil
}
+139
View File
@@ -0,0 +1,139 @@
package models
import (
"context"
"errors"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
// ErrNoEntitlement means the instance has no configuration row.
//
// Callers fall back to the plan's base rather than failing: staff manual
// issuance and any instance predating the backfill legitimately have none.
var ErrNoEntitlement = errors.New("instance has no entitlement")
// Config is one side of an entitlement — a complete statement of what an
// instance is allowed.
//
// Servers is the TOTAL the customer sees, not the number of units billed. The
// billed quantity is Servers minus the plan's base allowance, and it is computed
// where the line items are built rather than stored, so the two can never
// disagree about which of them included the base.
type Config struct {
Servers int `bson:"servers" json:"servers"`
Features Features `bson:"features" json:"features"`
}
// Entitlement is what one instance's customer configured.
//
// Both the subscription and the licence are derived from it; it is derived from
// nothing. Desired is what they last asked for; Granted is what a payment
// confirmed. A licence is only ever signed from Granted, so an abandoned
// checkout leaves a Desired that reached nothing.
type Entitlement struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
InstanceID string `bson:"instance_id" json:"instance_id"`
AccountID string `bson:"account_id" json:"account_id"`
Deployment string `bson:"deployment" json:"deployment"`
Tier string `bson:"tier" json:"tier"`
Term string `bson:"term" json:"term"`
Desired Config `bson:"desired" json:"desired"`
Granted Config `bson:"granted" json:"granted"`
// ResolvedLimits is BaseLimits with Granted folded in. It is stored rather
// than derived on read so the fold lives in exactly one place — deriving it
// at every read would put the arithmetic in the issuer, the portal and the
// staff console.
ResolvedLimits license.Limits `bson:"resolved_limits" json:"resolved_limits"`
// ScheduledChangeAt is when a pending REDUCTION takes effect. It is set only
// when Desired grants less than Granted, and it is what lets the portal say
// "drops to 5 on 12 August" instead of guessing.
ScheduledChangeAt *time.Time `bson:"scheduled_change_at,omitempty" json:"scheduled_change_at,omitempty"`
GrantedAt time.Time `bson:"granted_at" json:"granted_at"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
}
// Pending reports whether Desired and Granted disagree.
func (e Entitlement) Pending() bool {
if e.Desired.Servers != e.Granted.Servers {
return true
}
if len(e.Desired.Features) != len(e.Granted.Features) {
return true
}
have := map[string]bool{}
for _, f := range e.Granted.Features {
have[f] = true
}
for _, f := range e.Desired.Features {
if !have[f] {
return true
}
}
return false
}
// GetEntitlement reads one instance's configuration.
func GetEntitlement(ctx context.Context, instanceID string) (*Entitlement, error) {
var e Entitlement
err := db.Admin("entitlements").FindOne(ctx,
bson.M{"instance_id": instanceID}).Decode(&e)
if errors.Is(err, mongo.ErrNoDocuments) {
return nil, ErrNoEntitlement
}
if err != nil {
return nil, err
}
return &e, nil
}
// UpsertEntitlement writes an entitlement, creating it if absent.
//
// GrantedAt is only touched when Granted actually changes, which is what makes
// it answer "since when has this instance been allowed this" rather than "when
// was this row last written".
func UpsertEntitlement(ctx context.Context, e Entitlement) error {
now := time.Now().UTC()
set := bson.M{
"account_id": e.AccountID,
"deployment": e.Deployment,
"tier": e.Tier,
"term": e.Term,
"desired": e.Desired,
"granted": e.Granted,
"resolved_limits": e.ResolvedLimits,
"updated_at": now,
}
if e.ScheduledChangeAt != nil {
set["scheduled_change_at"] = *e.ScheduledChangeAt
}
if !e.GrantedAt.IsZero() {
set["granted_at"] = e.GrantedAt
} else {
set["granted_at"] = now
}
update := bson.M{"$set": set}
if e.ScheduledChangeAt == nil {
update["$unset"] = bson.M{"scheduled_change_at": ""}
}
_, err := db.Admin("entitlements").UpdateOne(ctx,
bson.M{"instance_id": e.InstanceID},
mergeSetOnInsert(update, bson.M{"instance_id": e.InstanceID}),
options.UpdateOne().SetUpsert(true))
return err
}
// mergeSetOnInsert adds a $setOnInsert clause without clobbering an existing one.
func mergeSetOnInsert(update bson.M, onInsert bson.M) bson.M {
update["$setOnInsert"] = onInsert
return update
}
+126 -16
View File
@@ -6,12 +6,41 @@
package models
import (
"encoding/json"
"time"
"github.com/mrhid6/vantage/shared/license"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"go.mongodb.org/mongo-driver/v2/bson"
)
// Features is a list of feature flags that marshals as `[]` rather than `null`.
//
// A nil Go slice becomes JSON null. The Free plan has no features, so every
// Free licence served `"features": null`, and the portal — whose type said
// string[] — called .length on it and took the page down with it.
//
// The guarantee lives on the type rather than at each of the six places a
// licence or plan is serialised, because the seventh is the one that would have
// been forgotten. It also fixes rows already holding null in Mongo, since it
// applies at marshal time rather than at write time.
type Features []string
func (f Features) MarshalJSON() ([]byte, error) {
if f == nil {
return []byte("[]"), nil
}
return json.Marshal([]string(f))
}
// OrEmpty is the same guarantee for values headed to Mongo rather than to JSON,
// so a null never enters the database in the first place.
func (f Features) OrEmpty() Features {
if f == nil {
return Features{}
}
return f
}
// Instance statuses.
const (
StatusAwaitingLink = "awaiting_link"
@@ -39,6 +68,28 @@ const (
ReasonTierChange = "tier_change"
ReasonRelink = "relink"
ReasonManual = "manual"
// ReasonEntitlementChange is a mid-term change to what an instance is
// allowed — servers added, a feature toggled — at the same expiry.
//
// It is deliberately NOT ReasonRenewal: a renewal resets relink_count
// because a new term has begun, and adding a server does not begin one.
ReasonEntitlementChange = "entitlement_change"
)
// Subscription statuses, mirrored from Paddle. Ours, not a vendor SDK's, so the
// billing package does not import anything Paddle.
const (
SubActive = "active"
SubCanceled = "canceled"
SubPastDue = "past_due"
SubTrialing = "trialing"
)
// Billing terms. These match catalogue price-ID keys and license.TermsFor.
const (
TermMonthly = "monthly"
TermAnnual = "annual"
)
// MaxRelinksPerTerm is the customer-facing relink cap.
@@ -60,6 +111,15 @@ const GracePeriod = 3 * 24 * time.Hour
// second mechanism.
const RenewWindow = 7 * 24 * time.Hour
// RenameCooldown is how long a customer must wait between renames of one
// instance.
//
// A rename moves the instance's DNS host and invalidates every saved link to it,
// so this exists to make that a considered act rather than a slider. Staff are
// not subject to it: a support conversation about a name is already a human
// deciding.
const RenameCooldown = 24 * time.Hour
type Account struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
AccountID string `bson:"account_id" json:"account_id"`
@@ -86,13 +146,35 @@ type Instance struct {
Status string `bson:"status" json:"status"`
CurrentLicense string `bson:"current_license,omitempty" json:"current_license,omitempty"`
RelinkCount int `bson:"relink_count" json:"relink_count"`
InjectFailedAt *time.Time `bson:"inject_failed_at,omitempty" json:"inject_failed_at,omitempty"`
// RenamedAt is when this instance last changed name, and backs the customer
// rename cooldown. It is a pointer because absent means "never renamed"; a
// zero time.Time would read as year 1 — an inert cooldown, but only by
// accident. Staff renames deliberately leave it alone.
RenamedAt *time.Time `bson:"renamed_at,omitempty" json:"renamed_at,omitempty"`
InjectFailedAt *time.Time `bson:"inject_failed_at,omitempty" json:"inject_failed_at,omitempty"`
// NoticesSent holds the lifecycle notice keys already emailed for the
// CURRENT term ("expiring", "expired", "delete_7", "delete_1"). Renewal
// clears it, so the next term starts the sequence again. It is what stops a
// restart re-sending a notice.
NoticesSent []string `bson:"notices_sent,omitempty" json:"notices_sent,omitempty"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
NoticesSent []string `bson:"notices_sent,omitempty" json:"notices_sent,omitempty"`
// Placeholder is true while a paid CLOUD instance row exists only so a
// checkout has something to attach custom_data to, before the confirmed
// payment provisions it. Cleared once provisioned. Self-hosted has no
// placeholder: its checkout names the install's real UUID.
Placeholder bool `bson:"placeholder,omitempty" json:"placeholder,omitempty"`
// PreviousInstanceIDs is every id this row has carried before its current one.
// A self-hosted row's identity is rewritten on each relink to a rebuilt
// server, and Paddle keeps its own copy of custom_data written at checkout.
// That copy
// is patched on each rewrite, but the patch is best-effort and any event
// already in flight still names an old id, so this is what lets a webhook
// resolve to the right instance instead of erroring as unknown.
PreviousInstanceIDs []string `bson:"previous_instance_ids,omitempty" json:"-"`
// PendingOwnerUserID is the customer_user who bought a paid-cloud placeholder,
// remembered so the confirmed-payment webhook can provision the instance with
// them as owner. Cleared once provisioned. Only ever set on a cloud placeholder.
PendingOwnerUserID string `bson:"pending_owner_user_id,omitempty" json:"-"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}
// License is append-only. A renewal writes a new row and sets SupersededBy on
@@ -107,7 +189,7 @@ type License struct {
Tier string `bson:"tier" json:"tier"`
Deployment string `bson:"deployment" json:"deployment"`
Limits license.Limits `bson:"limits" json:"limits"`
Features []string `bson:"features" json:"features"`
Features Features `bson:"features" json:"features"`
IssuedAt time.Time `bson:"issued_at" json:"issued_at"`
ExpiresAt time.Time `bson:"expires_at" json:"expires_at"`
Blob string `bson:"blob" json:"-"`
@@ -122,28 +204,56 @@ type Subscription struct {
AccountID string `bson:"account_id" json:"account_id"`
InstanceID string `bson:"instance_id,omitempty" json:"instance_id,omitempty"`
PaddleSubscriptionID string `bson:"paddle_subscription_id,omitempty" json:"paddle_subscription_id,omitempty"`
PaddlePriceID string `bson:"paddle_price_id,omitempty" json:"paddle_price_id,omitempty"`
Tier string `bson:"tier" json:"tier"`
Term string `bson:"term" json:"term"`
Status string `bson:"status" json:"status"`
CurrentPeriodEnd time.Time `bson:"current_period_end" json:"current_period_end"`
// Items is the full line-item list. Spec 7 made a subscription several
// prices — a base, a per-server unit at quantity N, an item per paid
// feature — so a single price ID can no longer describe it.
Items []SubItem `bson:"items,omitempty" json:"items,omitempty"`
}
// Plan is the authoritative tier definition, seeded from shared/license.
// SubItem is one line of a subscription: a price and its quantity, the shape
// catalogue.ResolveItems reads back into a plan and configuration.
type SubItem struct {
PriceID string `bson:"price_id" json:"price_id"`
Quantity int `bson:"quantity" json:"quantity"`
}
// PaddleEvent is the idempotency record for one webhook delivery. The unique
// index on EventID is what makes a retry a no-op rather than a second licence.
type PaddleEvent struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
EventID string `bson:"event_id" json:"event_id"`
EventType string `bson:"event_type" json:"event_type"`
ReceivedAt time.Time `bson:"received_at" json:"received_at"`
ProcessedAt *time.Time `bson:"processed_at,omitempty" json:"processed_at,omitempty"`
Error string `bson:"error,omitempty" json:"error,omitempty"`
}
// Plan is the authoritative definition of one (deployment, tier) pair, seeded
// from shared/license.
//
// It lives in the database so tier contents change without a deploy. Every
// issued licence snapshots it, so editing a plan never rewrites an existing
// licence — the same rule as workflow_runs.steps_snapshot.
//
// It holds NO Paddle identifiers. Every price ID lives in `catalogue`, because a
// metered plan is priced by several components and a single map on this row
// cannot express that.
type Plan struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
Tier string `bson:"tier" json:"tier"`
Name string `bson:"name" json:"name"`
Deployment string `bson:"deployment" json:"deployment"`
Limits license.Limits `bson:"limits" json:"limits"`
Features []string `bson:"features" json:"features"`
PaddleProductID string `bson:"paddle_product_id,omitempty" json:"paddle_product_id,omitempty"`
PaddlePriceIDs map[string]string `bson:"paddle_price_ids,omitempty" json:"paddle_price_ids,omitempty"`
Active bool `bson:"active" json:"active"`
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
Deployment string `bson:"deployment" json:"deployment"`
Tier string `bson:"tier" json:"tier"`
Name string `bson:"name" json:"name"`
// BaseLimits is the allowance before anything is bought. The field is named
// `base_` rather than `limits` because that is a different claim from the one
// the old field made, and a reader must not assume it is the total.
BaseLimits license.Limits `bson:"base_limits" json:"base_limits"`
BaseFeatures Features `bson:"base_features" json:"base_features"`
SupportLevel string `bson:"support_level" json:"support_level"`
Active bool `bson:"active" json:"active"`
}
type StaffUser struct {
+48
View File
@@ -0,0 +1,48 @@
package models
import (
"context"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
// ClaimEvent records an event ID before it is processed and reports whether THIS
// call is the one that claimed it.
//
// The unique index on event_id turns a duplicate insert into a duplicate-key
// error, which is the signal that another delivery of the same event already
// owns it — so this returns (false, nil) and the caller answers 200 without
// acting. A genuine error returns (false, err).
func ClaimEvent(ctx context.Context, eventID, eventType string) (bool, error) {
_, err := db.Admin("paddle_events").InsertOne(ctx, PaddleEvent{
EventID: eventID,
EventType: eventType,
ReceivedAt: time.Now().UTC(),
})
if err == nil {
return true, nil
}
if mongo.IsDuplicateKeyError(err) {
return false, nil
}
return false, err
}
// MarkEventProcessed stamps success, or records the error for staff visibility.
// A failed event keeps no processed_at, so a retry re-runs it.
func MarkEventProcessed(ctx context.Context, eventID string, procErr error) error {
set := bson.M{}
if procErr != nil {
set["error"] = procErr.Error()
} else {
now := time.Now().UTC()
set["processed_at"] = now
set["error"] = ""
}
_, err := db.Admin("paddle_events").UpdateOne(ctx,
bson.M{"event_id": eventID}, bson.M{"$set": set})
return err
}
+36 -26
View File
@@ -4,44 +4,54 @@ import (
"context"
"time"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/shared/license"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
// SeedPlans inserts the tier table from shared/license on first boot.
// SeedPlans inserts the six (deployment, tier) rows from shared/license on first
// boot.
//
// It uses $setOnInsert only: once a plan exists, staff edits to limits, features
// and Paddle IDs are authoritative and a redeploy must not stamp over them.
// It uses $setOnInsert only: once a plan exists, staff edits to allowances,
// features and support level are authoritative and a redeploy must not stamp
// over them.
func SeedPlans(ctx context.Context) error {
for _, tier := range []string{license.TierFree, license.TierProfessional, license.TierSelfHosted} {
p, ok := license.PlanFor(tier)
if !ok {
continue
}
_, err := db.Admin("plans").UpdateOne(ctx,
bson.M{"tier": tier},
bson.M{"$setOnInsert": bson.M{
"tier": p.Tier,
"name": p.Name,
"deployment": p.Deployment,
"limits": p.Limits,
"features": p.Features,
"active": true,
}},
options.UpdateOne().SetUpsert(true))
if err != nil {
return err
for _, deployment := range license.Deployments() {
for _, tier := range license.Tiers() {
p, ok := license.PlanFor(deployment, tier)
if !ok {
continue
}
_, err := db.Admin("plans").UpdateOne(ctx,
bson.M{"deployment": deployment, "tier": tier},
bson.M{"$setOnInsert": bson.M{
"deployment": p.Deployment,
"tier": p.Tier,
"name": p.Name,
"base_limits": p.Limits,
"base_features": Features(p.Features).OrEmpty(),
"support_level": p.SupportLevel,
"active": true,
}},
options.UpdateOne().SetUpsert(true))
if err != nil {
return err
}
}
}
return nil
}
// GetPlan reads a tier's authoritative definition.
func GetPlan(ctx context.Context, tier string) (*Plan, error) {
// GetPlan reads one pair's authoritative definition.
//
// It normalises the tier first, so a legacy self_hosted licence being reissued
// resolves to the plan that replaced it.
func GetPlan(ctx context.Context, deployment, tier string) (*Plan, error) {
deployment, tier = license.NormaliseTier(deployment, tier)
var p Plan
if err := db.Admin("plans").FindOne(ctx, bson.M{"tier": tier}).Decode(&p); err != nil {
if err := db.Admin("plans").FindOne(ctx,
bson.M{"deployment": deployment, "tier": tier}).Decode(&p); err != nil {
return nil, err
}
return &p, nil
+58
View File
@@ -0,0 +1,58 @@
// Package paddle is the only place that talks to Paddle. Everything outside it
// depends on the Client interface and our own types, never on Paddle's wire
// shapes — so a change at Paddle is confined to http.go, and the billing package
// can be reasoned about without knowing Paddle exists.
//
// It is a thin REST client rather than the vendor SDK on purpose: the surface we
// need is two calls, and a hand-rolled client has no version-drift risk and no
// dependency to keep in go.sum.
package paddle
import "context"
// LineItem is one price at a quantity, the shape both a checkout and a
// subscription update are built from.
type LineItem struct {
PriceID string
Quantity int
}
// Client is the narrow slice of Paddle admin needs. Checkout itself happens in
// the browser via paddle-js; the server only updates an existing subscription
// and mints a portal session.
type Client interface {
// UpdateSubscriptionItems replaces a subscription's items, prorated
// immediately by Paddle. This is the one outbound mutation, used when a
// customer changes their server count or features on an existing plan.
UpdateSubscriptionItems(ctx context.Context, paddleSubscriptionID string, items []LineItem) error
// UpdateSubscriptionCustomData replaces a subscription's custom_data. Used
// when a self-hosted instance is relinked to a rebuilt server: the checkout
// attached the old id, and every later webhook must name the new one.
UpdateSubscriptionCustomData(ctx context.Context, paddleSubscriptionID string, data map[string]string) error
// PortalSession returns a customer-portal URL for managing billing.
PortalSession(ctx context.Context, paddleCustomerID string) (string, error)
// Env is "sandbox" or "production", the same value catalogue price lookups
// are keyed on.
Env() string
}
var current Client
// Init constructs the client from config and stores it. Called once at boot.
func Init(apiKey, env string) (Client, error) {
c, err := newHTTPClient(apiKey, env)
if err != nil {
return nil, err
}
current = c
return c, nil
}
// Get returns the client initialised at boot. Panics if unset, which can only
// happen if a caller runs before Init — a programming error, not a runtime one.
func Get() Client {
if current == nil {
panic("paddle.Get before paddle.Init")
}
return current
}
+131
View File
@@ -0,0 +1,131 @@
package paddle
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// httpClient is the only implementation of Client. It is the single place that
// knows Paddle's base URLs, auth header and request shapes — swap the whole
// vendor here without the rest of the tree noticing.
type httpClient struct {
apiKey string
env string
base string
http *http.Client
}
func newHTTPClient(apiKey, env string) (Client, error) {
if apiKey == "" {
return nil, fmt.Errorf("paddle: empty API key")
}
base := "https://sandbox-api.paddle.com"
if env == "production" {
base = "https://api.paddle.com"
}
return &httpClient{
apiKey: apiKey,
env: env,
base: base,
http: &http.Client{Timeout: 20 * time.Second},
}, nil
}
func (c *httpClient) Env() string { return c.env }
// do sends a JSON request and decodes the `data` envelope Paddle wraps every
// response in. A non-2xx is returned as an error carrying the body, so a
// configuration or auth failure is loud rather than silent.
func (c *httpClient) do(ctx context.Context, method, path string, body any, out any) error {
var buf io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("paddle: marshal %s %s: %w", method, path, err)
}
buf = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, c.base+path, buf)
if err != nil {
return fmt.Errorf("paddle: build %s %s: %w", method, path, err)
}
req.Header.Set("Authorization", "Bearer "+c.apiKey)
req.Header.Set("Content-Type", "application/json")
res, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("paddle: %s %s: %w", method, path, err)
}
defer res.Body.Close()
raw, _ := io.ReadAll(res.Body)
if res.StatusCode < 200 || res.StatusCode >= 300 {
return fmt.Errorf("paddle: %s %s returned %d: %s", method, path, res.StatusCode, string(raw))
}
if out == nil {
return nil
}
if err := json.Unmarshal(raw, out); err != nil {
return fmt.Errorf("paddle: decode %s %s: %w", method, path, err)
}
return nil
}
type updateSubscriptionRequest struct {
Items []reqItem `json:"items"`
ProrationBillingMode string `json:"proration_billing_mode"`
}
type reqItem struct {
PriceID string `json:"price_id"`
Quantity int `json:"quantity"`
}
func (c *httpClient) UpdateSubscriptionItems(ctx context.Context, subID string, items []LineItem) error {
if subID == "" {
return fmt.Errorf("paddle: empty subscription id")
}
reqItems := make([]reqItem, 0, len(items))
for _, it := range items {
reqItems = append(reqItems, reqItem{PriceID: it.PriceID, Quantity: it.Quantity})
}
return c.do(ctx, http.MethodPatch, "/subscriptions/"+subID, updateSubscriptionRequest{
Items: reqItems,
ProrationBillingMode: "prorated_immediately",
}, nil)
}
// UpdateSubscriptionCustomData patches custom_data only. Paddle replaces the
// whole object, so callers pass every key they want to keep.
func (c *httpClient) UpdateSubscriptionCustomData(ctx context.Context, subID string, data map[string]string) error {
if subID == "" {
return fmt.Errorf("paddle: empty subscription id")
}
return c.do(ctx, http.MethodPatch, "/subscriptions/"+subID, struct {
CustomData map[string]string `json:"custom_data"`
}{CustomData: data}, nil)
}
func (c *httpClient) PortalSession(ctx context.Context, customerID string) (string, error) {
if customerID == "" {
return "", fmt.Errorf("paddle: empty customer id")
}
var out struct {
Data struct {
URLs struct {
General struct {
Overview string `json:"overview"`
} `json:"general"`
} `json:"urls"`
} `json:"data"`
}
if err := c.do(ctx, http.MethodPost,
"/customers/"+customerID+"/portal-sessions", struct{}{}, &out); err != nil {
return "", err
}
return out.Data.URLs.General.Overview, nil
}
+42
View File
@@ -0,0 +1,42 @@
package paddle
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strings"
)
// VerifySignature checks a raw webhook body against the Paddle-Signature header.
//
// Paddle signs an HMAC-SHA256 over "ts:body", carried as "ts=<unix>;h1=<hex>".
// It uses a constant-time compare and never logs the secret. A false return is
// always a 401 with nothing processed — an unverified body could be anyone
// claiming a subscription was paid for.
func VerifySignature(secret, header string, body []byte) bool {
if secret == "" || header == "" {
return false
}
var ts, h1 string
for _, part := range strings.Split(header, ";") {
k, v, ok := strings.Cut(part, "=")
if !ok {
continue
}
switch k {
case "ts":
ts = v
case "h1":
h1 = v
}
}
if ts == "" || h1 == "" {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(ts))
mac.Write([]byte(":"))
mac.Write(body)
want := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(want), []byte(h1))
}
+12
View File
@@ -19,6 +19,18 @@ ENV NEXT_PUBLIC_ADMIN_API_URL=$NEXT_PUBLIC_ADMIN_API_URL
ARG NEXT_PUBLIC_ADMIN_ENV=production
ENV NEXT_PUBLIC_ADMIN_ENV=$NEXT_PUBLIC_ADMIN_ENV
# Browser checkout. The client token and environment are baked in, never
# fetched, so a production build cannot load a sandbox token by accident.
ARG NEXT_PUBLIC_PADDLE_CLIENT_TOKEN=
ENV NEXT_PUBLIC_PADDLE_CLIENT_TOKEN=$NEXT_PUBLIC_PADDLE_CLIENT_TOKEN
ARG NEXT_PUBLIC_PADDLE_ENV=sandbox
ENV NEXT_PUBLIC_PADDLE_ENV=$NEXT_PUBLIC_PADDLE_ENV
# Marketing site origin. Signup lives there (/start), not here; empty renders no
# link at all rather than one that 404s.
ARG NEXT_PUBLIC_SITE_URL=
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
RUN npm run build
FROM node:26-alpine AS runner
+112 -47
View File
@@ -3,60 +3,125 @@
import { useQuery } from "@tanstack/react-query";
import { API_BASE, NotConnected, api } from "@/lib/api";
import { NotConnectedPanel } from "@/components/NotConnected";
import { formatDate } from "@/lib/format";
import { PageFrame, RailCard, RailFacts } from "@/components/PageFrame";
import { PageHeader } from "@/components/PageHeader";
import { ManageBillingButton } from "@/components/ManageBillingButton";
import { TermSpark } from "@/components/TermBar";
import { EmptyState, Panel } from "@/components/Panel";
import { Sub, TBody, TD, TH, THead, TR, Table } from "@/components/Table";
import { formatDate, licenceState } from "@/lib/format";
export default function BillingPage() {
const { data, error, isLoading } = useQuery({
queryKey: ["subscriptions"],
queryFn: api.subscriptions,
});
const subs = useQuery({ queryKey: ["subscriptions"], queryFn: api.subscriptions });
const account = useQuery({ queryKey: ["account"], queryFn: api.account });
if (error instanceof NotConnected) return <NotConnectedPanel url={API_BASE} />;
if (isLoading) return <p className="text-ink-3">Loading</p>;
if (subs.error instanceof NotConnected) return <NotConnectedPanel url={API_BASE} />;
if (subs.isLoading) return <p className="text-ink-3">Loading</p>;
const rows = subs.data ?? [];
// Each subscription names the instance it pays for, because tier and term
// are per-licence rather than per-account. Resolving the name here is the
// difference between "professional · annual" and knowing which install that is.
const nameFor = (instanceId?: string) => account.data?.instances.find((i) => i.instance_id === instanceId)?.name;
/*
* A subscription reports when the period ends but not when it began, so the
* start is derived from the term. Only the two terms we actually sell are
* handled — anything else returns null and the row falls back to the date
* alone, because a bar drawn from a guessed span is worse than no bar.
*/
const periodStart = (end: string, term: string): string | null => {
const months = /ann|year/i.test(term) ? 12 : /month/i.test(term) ? 1 : 0;
if (!months) return null;
const d = new Date(end);
if (Number.isNaN(d.getTime())) return null;
d.setMonth(d.getMonth() - months);
return d.toISOString();
};
return (
<div className="grid gap-6">
<h1 className="text-3xl">Billing</h1>
<PageHeader
title="Billing"
subtitle="One subscription per instance each carries its own tier and term."
record={account.data ? [{ key: "Billing", value: account.data.account.billing_email }] : undefined}
/>
{!data || data.length === 0 ? (
<p className="text-ink-2">
You have no subscriptions. Cloud instances and self-hosted licences are both
bought from the pricing page.
</p>
) : (
<div className="overflow-x-auto rounded border border-rule bg-panel">
<table className="w-full border-collapse text-left">
<thead>
<tr className="border-b border-rule bg-panel-2 font-mono text-[0.72rem] uppercase tracking-[0.08em] text-ink-3">
<th className="px-4 py-2.5">Plan</th>
<th className="px-4 py-2.5">Term</th>
<th className="px-4 py-2.5">Status</th>
<th className="px-4 py-2.5">Renews</th>
</tr>
</thead>
<tbody>
{data.map((s) => (
<tr
key={s.subscription_id}
className="border-b border-rule-soft last:border-0"
>
<td className="px-4 py-3">{s.tier.replace("_", " ")}</td>
<td className="px-4 py-3">{s.term}</td>
<td className="px-4 py-3">{s.status}</td>
<td className="px-4 py-3 font-mono tabular-nums">
{formatDate(s.current_period_end)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<p className="max-w-xl text-[0.82rem] text-ink-3">
To change a card, download an invoice or cancel, email support and we will send you
a billing link. Self-service billing arrives with card payments.
</p>
<PageFrame
aside={
<>
<RailCard title="Account">
<RailFacts
rows={[
{
label: "Billing contact",
value: account.data?.account.billing_email ?? "—",
},
{ label: "Status", value: account.data?.account.status ?? "—" },
{ label: "Subscriptions", value: rows.length },
]}
/>
</RailCard>
<RailCard title="Need a change?">
<p className="text-[0.82rem] text-ink-2">Change a card, download an invoice or cancel from the billing portal. It covers every subscription on this account.</p>
<ManageBillingButton />
<p className="text-[0.82rem] text-ink-2">Anything else, email support.</p>
<a href="mailto:support@hostxtra.co.uk" className="text-[0.82rem] font-semibold text-accent underline">
support@hostxtra.co.uk
</a>
</RailCard>
</>
}
>
<Panel title="Subscriptions" meta={rows.length ? `${rows.length}` : undefined} bodyless>
{rows.length === 0 ? (
<EmptyState
title="No subscriptions yet."
body="Cloud instances and self-hosted licences are both bought from the plan page, and each one bills separately."
/>
) : (
<Table stack>
<THead>
<TR className="hover:bg-transparent">
<TH>Instance</TH>
<TH>Plan</TH>
<TH>Billing</TH>
<TH>Status</TH>
<TH>Renews</TH>
</TR>
</THead>
<TBody>
{rows.map((s) => {
const start = periodStart(s.current_period_end, s.term);
const name = nameFor(s.instance_id);
return (
<TR key={s.subscription_id}>
<TD label="Instance">
{name ?? <span className="text-ink-3">Not linked yet</span>}
{name && <Sub>{s.instance_id?.slice(0, 8)}</Sub>}
</TD>
<TD label="Plan">{s.tier.replace("_", " ")}</TD>
<TD label="Billing" className="text-ink-2">
{s.term}
</TD>
<TD label="Status" className="text-ink-2">
{s.status}
</TD>
<TD label="Renews">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
{start && <TermSpark issuedAt={start} expiresAt={s.current_period_end} state={licenceState(s.current_period_end, true)} />}
<span className="font-mono text-[0.78rem] tabular-nums text-ink-2">{formatDate(s.current_period_end)}</span>
</div>
</TD>
</TR>
);
})}
</TBody>
</Table>
)}
</Panel>
</PageFrame>
</div>
);
}
+225 -61
View File
@@ -2,14 +2,64 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import { useState } from "react";
import { API_BASE, ApiError, NotConnected, api } from "@/lib/api";
import { API_BASE, ApiError, NotConnected, api, type License } from "@/lib/api";
import { NotConnectedPanel } from "@/components/NotConnected";
import { LicenceDelivery } from "@/components/LicenceDelivery";
import { MembersPanel } from "@/components/MembersPanel";
import { RelinkPanel } from "@/components/RelinkPanel";
import { RenamePanel } from "@/components/RenamePanel";
import { StatePill } from "@/components/StatePill";
import { TermBar } from "@/components/TermBar";
import { EmptyState, Note, Panel } from "@/components/Panel";
import { PageFrame, RailCard } from "@/components/PageFrame";
import { PageHeader } from "@/components/PageHeader";
import { LinkButton } from "@/components/Button";
import { formatDate, licenceState, limitLabel } from "@/lib/format";
import { FEATURE_LABEL, featureDesc, featureLabel } from "@/lib/features";
import { useSession } from "@/lib/session";
/** One key/value row. The key is the same keyed idiom as everywhere else. */
function Row({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex items-baseline justify-between gap-4">
<dt className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">{label}</dt>
<dd className="m-0 text-[0.88rem] tabular-nums">{value}</dd>
</div>
);
}
/*
* Every feature the product sells, granted or not.
*
* Listing only what is included answers "what do I have" but not "what am I
* missing", which is the question someone on this screen is actually weighing
* before they click Change plan. The absent ones are struck through rather than
* omitted, so the comparison is on the page instead of in another tab.
*/
function Features({ granted }: { granted: string[] }) {
const all = Object.keys(FEATURE_LABEL);
// Anything the licence carries that this build does not know about is still
// shown — the map degrades to the raw key, which is ugly but never wrong.
const extras = granted.filter((f) => !all.includes(f));
return (
<div className="grid gap-2">
<span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">Features</span>
<div className="flex flex-wrap gap-1.5">
{[...all, ...extras].map((f) => {
const on = granted.includes(f);
return (
<span key={f} title={featureDesc(f) || undefined} className={on ? "rounded-sm border border-rule px-2 py-0.5 text-[0.78rem] text-ink-2" : "rounded-sm border border-rule-soft px-2 py-0.5 text-[0.78rem] text-ink-3 line-through decoration-ink-3/60"}>
{featureLabel(f)}
</span>
);
})}
</div>
</div>
);
}
export default function InstancePage() {
const id = String(useParams().id);
@@ -17,6 +67,10 @@ export default function InstancePage() {
const qc = useQueryClient();
const [relinkError, setRelinkError] = useState<string | undefined>();
// useSession is the app's one way to ask who the caller is — it shares the
// ["me"] query, so this adds no request.
const { session } = useSession();
const account = useQuery({ queryKey: ["account"], queryFn: api.account });
const licence = useQuery({
queryKey: ["license", id],
@@ -26,12 +80,11 @@ export default function InstancePage() {
const relink = useMutation({
mutationFn: (newId: string) => api.relink(id, newId),
onSuccess: (lic) => {
onSuccess: (lic: License) => {
qc.invalidateQueries({ queryKey: ["account"] });
router.replace(`/instances/${lic.instance_id}`);
},
onError: (err) =>
setRelinkError(err instanceof ApiError ? err.message : "Relink failed. Try again."),
onError: (err) => setRelinkError(err instanceof ApiError ? err.message : "Relink failed. Try again."),
});
if (account.error instanceof NotConnected) return <NotConnectedPanel url={API_BASE} />;
@@ -47,68 +100,179 @@ export default function InstancePage() {
const lic = licence.data;
const state = licenceState(lic?.expires_at, Boolean(lic));
const cloud = instance.deployment === "cloud";
const mayRename = session?.account_role === "owner" || session?.account_role === "admin";
const maxRelinks = account.data?.max_relinks ?? 3;
const host = cloud && instance.slug ? `${instance.slug}.vantage.hostxtra.co.uk` : null;
return (
<div className="grid gap-8">
<header className="grid gap-3">
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-3xl">{instance.name}</h1>
<StatePill state={state} />
</div>
<p className="font-mono text-[0.82rem] tabular-nums text-ink-3">
{instance.instance_id}
</p>
</header>
<div className="grid gap-6">
<PageHeader
back={{ href: "/", label: "Overview" }}
title={instance.name || "Unnamed instance"}
subtitle={`${cloud ? "Cloud" : "Self-hosted"} instance${instance.tier ? ` on ${instance.tier.replace("_", " ")}` : ""} · created ${formatDate(instance.created_at)}`}
/*
* The two things this screen is for, in the header rather than
* hunted for further down. Download is self-hosted only: a cloud
* licence is injected into the control plane directly and there
* is nothing for the customer to do with the file.
*/
actions={
<>
{lic && !cloud && (
<LinkButton variant="line" external href={api.licenseBlobUrl(instance.instance_id)}>
Download licence
</LinkButton>
)}
{lic && <LinkButton href="/purchase">Renew licence</LinkButton>}
</>
}
record={[{ key: "Instance", value: instance.instance_id, copy: true }, ...(lic ? [{ key: "Licence", value: lic.license_id, copy: true }] : [])]}
status={<StatePill state={state} />}
/>
{lic ? (
<>
<dl className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Fact label="Tier" value={lic.tier.replace("_", " ")} />
<Fact label="Expires" value={formatDate(lic.expires_at)} />
<Fact label="Servers" value={limitLabel(lic.limits.max_servers)} />
<Fact label="Features" value={lic.features.join(", ") || "none"} />
</dl>
<PageFrame
aside={
host ? (
<RailCard title="Console">
<p className="text-[0.82rem] text-ink-2">Servers, workflows and monitors live in the instance itself.</p>
<a href={`https://${host}`} className="inline-flex items-center justify-center gap-2 rounded border border-rule px-3 py-2 text-[0.84rem] font-semibold text-ink no-underline hover:border-accent hover:text-accent">
Open {instance.name || "instance"} &rarr;
</a>
<p className="font-mono text-[0.72rem] text-ink-3">{host}</p>
</RailCard>
) : undefined
}
>
{/*
* The term leads. This screen is about one licence, and the rail
* carried its issue and expiry dates as two lines of text —
* which is the arithmetic this bar does for the reader.
*/}
{lic && (
<Panel title="Licence" meta={`${lic.tier.replace("_", " ")} · ${cloud ? "Cloud" : "Self-hosted"}`}>
<TermBar issuedAt={lic.issued_at} expiresAt={lic.expires_at} state={state} />
{instance.deployment === "self_hosted" && (
<>
<LicenceDelivery
instanceId={instance.instance_id}
blob={lic.blob ?? ""}
downloadUrl={api.licenseBlobUrl(instance.instance_id)}
/>
<RelinkPanel
instanceId={instance.instance_id}
used={instance.relink_count}
max={account.data?.max_relinks ?? 3}
error={relinkError}
onRelink={(newId) => relink.mutate(newId)}
/>
</>
)}
</>
) : (
<p className="text-ink-2">No licence has been issued for this instance yet.</p>
)}
{state === "warn" && <Note tone="warn">Inside 14 days of expiry. Renewing extends the term from the current expiry, not from today, so nothing is lost by renewing early.</Note>}
{state === "expired" && <Note tone="expired">A lapsed licence does not stop the control plane: agents carry on reporting and your servers keep their keys. It stops accepting changes, so nothing new can be deployed until this is renewed.</Note>}
</Panel>
)}
{instance.deployment === "cloud" ? (
<MembersPanel instanceId={instance.instance_id} />
) : (
<p className="rounded border border-rule bg-panel p-5 text-ink-2">
Users for this install are managed inside it, in Settings Instance. We do not
have access to your own deployment.
</p>
)}
</div>
);
}
function Fact({ label, value }: { label: string; value: string }) {
return (
<div className="grid gap-1">
<dt className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
{label}
</dt>
<dd className="font-mono tabular-nums">{value}</dd>
{/*
* What the licence grants, on the screen about that licence.
* These were four rows in a 320px rail card, which is where
* facts go when nobody has decided they matter.
*/}
{lic && (
<Panel
title="Included"
/* A panel-header action is a quiet link, not a second
full-size button competing with the header's Renew. */
actions={
<Link href="/purchase" className="font-mono text-[0.7rem] uppercase tracking-[0.1em] text-accent no-underline hover:underline">
Change plan &rarr;
</Link>
}
>
<div className="grid gap-x-8 gap-y-2.5 sm:grid-cols-2">
<dl className="grid content-start gap-2.5">
<Row label="Servers" value={limitLabel(lic.limits.max_servers)} />
<Row label="Monitors" value={limitLabel(lic.limits.max_monitors)} />
<Row label="Secret groups" value={limitLabel(lic.limits.max_secret_groups)} />
</dl>
<dl className="grid content-start gap-2.5">
<Row label="Channels" value={limitLabel(lic.limits.max_channels)} />
<Row label="Audit history" value={`${limitLabel(lic.limits.audit_retention_days)} days`} />
<Row label="Issued for" value={lic.reason.replace("_", " ")} />
</dl>
</div>
<Features granted={lic.features} />
</Panel>
)}
{/*
* On a self-hosted instance the licence is the errand: someone
* opens this page to fetch the blob and paste it. It sits
* directly under the term, above the panels that only explain
* things.
*/}
{lic && !cloud && <LicenceDelivery instanceId={instance.instance_id} blob={lic.blob ?? ""} downloadUrl={api.licenseBlobUrl(instance.instance_id)} />}
{cloud && <MembersPanel instanceId={instance.instance_id} />}
{/*
* Address rather than "Rename": the panel is about where this
* instance lives, and the rename is how you change it. Cloud
* only — a self-hosted install has no tenant subdomain for us to
* move.
*/}
{cloud && mayRename && (
<Panel title="Address" meta={host ?? undefined}>
<p className="text-[0.86rem] text-ink-2">
The instance name is where its address comes from. Renaming moves it to a new address and releases the old
one, so saved links and bookmarks to it stop working.
</p>
{/*
* Keyed on the instance: this element stays mounted
* across a navigation between two instance pages, so
* without a key the success note and the typed name
* from one instance surface on the next.
*/}
<RenamePanel
key={instance.instance_id}
movesHost
currentName={instance.name}
currentSlug={instance.slug ?? ""}
onRename={async (name) => {
const res = await api.renameInstance(instance.instance_id, name);
qc.invalidateQueries({ queryKey: ["account"] });
return res;
}}
/>
</Panel>
)}
{/*
* "Moves" rather than "Relinks": the count is rationed, so the
* headline is how many are left, and the panel explains what
* spends one. Cloud instances cannot move — we own the host —
* so the panel is absent rather than present and refusing.
*/}
{!cloud && (
<Panel title="Moves" meta={`${Math.max(0, maxRelinks - instance.relink_count)} of ${maxRelinks} left`}>
<p className="text-[0.86rem] text-ink-2">
A licence binds to one install. Rebuilding the host, or moving to different hardware, needs a replacement licence bound to the new ID
that is a move, and it covers the rest of your current term.
</p>
<RelinkPanel instanceId={instance.instance_id} used={instance.relink_count} max={maxRelinks} error={relinkError} onRelink={(newId) => relink.mutate(newId)} />
</Panel>
)}
{/*
* A panel holding one sentence has not decided what it is for.
* For a self-hosted install the useful content is not "we don't
* do this" but where the thing they came looking for actually
* lives — and why the people on their HQ account are not it.
*/}
{!cloud && (
<Panel title="Who can sign in" meta="Managed in your install">
<p className="text-[0.86rem] text-ink-2">
You run this deployment, so its users live inside it rather than here. Add and remove them in the instance&rsquo;s own settings.
</p>
<p className="text-[0.82rem] text-ink-3">
People on your Vantage HQ account can see billing and this licence. That is separate from who can sign in to the instance, and granting
one never grants the other.
</p>
</Panel>
)}
{!lic && (
<Panel bodyless>
<EmptyState title="No licence issued yet." body="A licence binds to one install, so it is issued once this instance is linked to the ID its install reports." action={<LinkButton href="/purchase">Get a licence</LinkButton>} />
</Panel>
)}
</PageFrame>
</div>
);
}
@@ -1,72 +0,0 @@
"use client";
import { useState } from "react";
import { ApiError, NotConnected, api } from "@/lib/api";
import { Button } from "@/components/Button";
import { Field } from "@/components/Field";
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export function LinkForm({ onLinked }: { onLinked: (instanceId: string) => void }) {
const [id, setId] = useState("");
const [name, setName] = useState("");
const [error, setError] = useState<string | undefined>();
const [busy, setBusy] = useState(false);
async function submit(e: React.FormEvent) {
e.preventDefault();
const value = id.trim();
// Checked here so a typo costs nothing and the message is instant.
if (!UUID_RE.test(value)) {
setError(
"That does not look like an instance ID. It should look like the example below.",
);
return;
}
setBusy(true);
setError(undefined);
try {
const inst = await api.link(value, name.trim());
onLinked(inst.instance_id);
} catch (err) {
setError(
err instanceof NotConnected
? "The licensing service is not reachable from this page."
: err instanceof ApiError
? err.message
: "Could not link that instance. Try again.",
);
} finally {
setBusy(false);
}
}
return (
<form onSubmit={submit} className="grid gap-4" noValidate>
<Field
label="Instance ID"
value={id}
onChange={(e) => setId(e.target.value)}
error={error}
hint={
<>
Find this on your install&rsquo;s <code>Settings Licence</code> page, or on
the setup screen just after you first sign in. It looks like{" "}
<code>6a0fe3f0-49d2-4aa1-967c-a3094b200b5d</code>.
</>
}
/>
<Field
label="Name it (optional)"
value={name}
onChange={(e) => setName(e.target.value)}
hint="So you can tell it apart from your other installs."
/>
<Button type="submit" disabled={busy} className="justify-self-start">
{busy ? "Linking…" : "Link and issue licence"}
</Button>
</form>
);
}
@@ -1,30 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { LinkForm } from "./LinkForm";
export default function LinkPage() {
const router = useRouter();
const qc = useQueryClient();
return (
<div className="grid max-w-2xl gap-6">
<header className="grid gap-2">
<h1 className="text-3xl">Link an install</h1>
<p className="text-ink-2">
Every licence is tied to one install, so we need its ID before we can issue
yours. Paste it below and your licence is ready on the next screen.
</p>
</header>
<LinkForm
onLinked={(instanceId) => {
qc.invalidateQueries({ queryKey: ["account"] });
// Straight to the download, not back to a list: the licence is
// the thing they came for.
router.push(`/instances/${instanceId}`);
}}
/>
</div>
);
}
@@ -1,65 +0,0 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { ApiError, api } from "@/lib/api";
import { Button } from "@/components/Button";
import { Field } from "@/components/Field";
function slugify(value: string) {
return value
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
}
export function CreateForm() {
const [name, setName] = useState("");
const [error, setError] = useState<string | null>(null);
const router = useRouter();
const qc = useQueryClient();
const create = useMutation({
mutationFn: () => api.createInstance(name.trim()),
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: ["account"] });
router.push("/");
},
onError: (e) =>
setError(e instanceof ApiError ? e.message : "Something went wrong. Try again."),
});
const slug = slugify(name);
return (
<form
className="grid max-w-md gap-4"
onSubmit={(e) => {
e.preventDefault();
setError(null);
if (name.trim()) create.mutate();
}}
>
<Field
label="Instance name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Northgate Systems"
required
error={error ?? undefined}
hint={`${slug || "your-instance"}.vantage.hostxtra.co.uk`}
/>
<p className="text-[0.82rem] text-ink-2">
You sign in to it with this same email address and password. Changing your Vantage
HQ password changes it here too.
</p>
<Button type="submit" disabled={create.isPending || !name.trim()}>
{create.isPending ? "Creating…" : "Create instance"}
</Button>
</form>
);
}
@@ -1,20 +0,0 @@
import type { Metadata } from "next";
import { CreateForm } from "./CreateForm";
export const metadata: Metadata = { title: "New instance" };
export default function NewInstancePage() {
return (
<div className="grid gap-6">
<header className="grid gap-2">
<h1 className="text-3xl">Create a free instance</h1>
<p className="max-w-prose text-ink-2">
An instance owns its servers, keys, workflows, monitors and secrets. Nothing
inside it is visible to any other instance. Free covers three servers, and the
licence runs for a month at a time we email you before it needs renewing.
</p>
</header>
<CreateForm />
</div>
);
}
+25 -21
View File
@@ -1,31 +1,35 @@
"use client";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";
import { RequireKind } from "@/lib/session";
import { AppBar, type NavLink } from "@/components/AppBar";
/*
* Three destinations, not five. Settings moved into the account menu it is
* your password, not a place and Instances went with it, because Overview
* already lists them and a second door to the same room is just a second thing
* to keep in sync. Linking an install is an action, so it is a button on
* Overview rather than a permanent nav entry.
*/
const LINKS: NavLink[] = [
{ href: "/", label: "Overview" },
{ href: "/users", label: "People" },
{ href: "/billing", label: "Billing" },
];
function AccountName() {
// Shares the ["account"] key with Overview, so this costs no extra request.
const { data } = useQuery({ queryKey: ["account"], queryFn: api.account });
if (!data) return null;
return <span className="block truncate text-[0.92rem] font-bold tracking-[-0.01em]">{data.account.name}</span>;
}
export default function CustomerLayout({ children }: { children: React.ReactNode }) {
return (
<RequireKind kind="customer">
<nav className="border-b border-rule-soft bg-panel-2">
<div className="mx-auto flex max-w-rail flex-wrap gap-5 px-5 py-2.5 font-mono text-[0.72rem] uppercase tracking-[0.06em]">
<Link href="/" className="text-accent">
Overview
</Link>
<Link href="/instances/link" className="text-ink-3">
Link an install
</Link>
<Link href="/billing" className="text-ink-3">
Billing
</Link>
<Link href="/users" className="text-ink-3">
People
</Link>
<Link href="/settings" className="text-ink-3">
Settings
</Link>
</div>
</nav>
<main className="mx-auto max-w-rail px-5 py-8">{children}</main>
<AppBar links={LINKS} context={<AccountName />} />
<main className="mx-auto max-w-rail px-5 py-7">{children}</main>
</RequireKind>
);
}
+204 -60
View File
@@ -4,10 +4,17 @@ import { useQueries, useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { API_BASE, NotConnected, api, type License } from "@/lib/api";
import { NotConnectedPanel } from "@/components/NotConnected";
import { InstanceCard } from "@/components/InstanceCard";
import { InstanceRecord } from "@/components/InstanceRecord";
import { PageFrame, RailCard, RailFacts } from "@/components/PageFrame";
import { Panel } from "@/components/Panel";
import { PageHeader } from "@/components/PageHeader";
import { LinkButton } from "@/components/Button";
import { StatePill } from "@/components/StatePill";
import { daysRemaining, formatDate, licenceState } from "@/lib/format";
export default function OverviewPage() {
const { data, error, isLoading } = useQuery({ queryKey: ["account"], queryFn: api.account });
const people = useQuery({ queryKey: ["account-users"], queryFn: api.accountUsers });
const licences = useQueries({
queries: (data?.instances ?? [])
@@ -26,72 +33,209 @@ export default function OverviewPage() {
if (q.data) byInstance.set(q.data.instance_id, q.data);
});
const unlinked = data.instances.filter((i) => i.status === "awaiting_link");
const live = data.instances.filter((i) => i.status !== "deleted");
/*
* Work the customer has to do, gathered across every instance. This is the
* only account-level view of it — each record only knows about itself.
*
* Each item carries the way out of it. It used to be a list of sentences in
* the rail, which told someone their licence was expiring and then made
* them go and find the instance that owned it; the fix for every one of
* these is one click, so the click belongs on the row.
*/
const attention = live.flatMap((i) => {
const lic = byInstance.get(i.instance_id);
const state = licenceState(lic?.expires_at, Boolean(lic));
const name = i.name || "An instance";
if (state === "none")
return [
{
id: i.instance_id,
text: `${name} has no licence yet`,
note: "Pick a plan and we will issue a licence for this install.",
href: "/purchase",
action: "Get a licence",
tag: "",
},
];
if (state === "expired")
return [
{
id: i.instance_id,
text: `${name} has expired`,
note: "Servers keep running and agents keep their keys, but changes are disabled until you renew.",
href: `/instances/${i.instance_id}`,
action: "Renew",
tag: "now",
},
];
if (state === "warn") {
const d = daysRemaining(lic!.expires_at);
return [
{
id: i.instance_id,
text: `${name} expires in ${d} ${d === 1 ? "day" : "days"}`,
note: "Renewing extends the term from the current expiry, so nothing is lost by renewing early.",
href: `/instances/${i.instance_id}`,
action: "Renew",
tag: `${d}d`,
},
];
}
return [];
});
const pending = (people.data ?? []).filter((p) => !p.verified_at).length;
const subtitle =
live.length === 0 ? "Nothing here yet." : `${live.length} ${live.length === 1 ? "instance" : "instances"}${attention.length ? ` · ${attention.length} needing attention` : " · all licensed"}`;
return (
<div className="grid gap-8">
<header className="grid gap-2">
<h1 className="text-3xl">{data.account.name}</h1>
<p className="text-ink-2">{data.account.billing_email}</p>
</header>
<div className="grid gap-6">
<PageHeader
title="Overview"
subtitle={subtitle}
actions={live.length > 0 ? <LinkButton href="/purchase">Buy a plan</LinkButton> : undefined}
record={[
{ key: "Account", value: data.account.account_id, copy: true },
{ key: "Billing", value: data.account.billing_email },
]}
status={attention.length === 0 && live.length > 0 ? <StatePill state="valid" /> : undefined}
/>
{unlinked.length > 0 && (
<div className="rounded border border-accent bg-accent-wash p-4">
<h2 className="text-xl">Finish setting up your licence</h2>
<p className="mt-1 text-[0.82rem] text-ink-2">
{unlinked.length === 1
? "One purchase is"
: `${unlinked.length} purchases are`}{" "}
not attached to an install yet, so no licence has been issued for{" "}
{unlinked.length === 1 ? "it" : "them"}.
</p>
<Link
href="/instances/link"
className="mt-2 inline-block text-[0.82rem] font-semibold text-accent underline"
>
Link an install
</Link>
</div>
)}
{live.length === 0 ? (
/*
* An empty screen is an invitation to act, and the two ways in
* are genuinely different products — we host it, or you do. One
* button and a paragraph explaining the other option made the
* self-hosted path read as an afterthought, which it is not.
*/
<div className="grid gap-4 rounded border border-rule bg-panel p-6">
<div className="grid gap-2">
<h2 className="text-xl">No instances yet</h2>
<p className="max-w-[52ch] text-ink-2">An instance is one Vantage control plane. Start a hosted one in about a minute, or license an install you run yourself.</p>
</div>
{data.instances.length === 0 ? (
<div className="grid max-w-xl gap-3 rounded border border-rule bg-panel p-5">
<h2 className="text-xl">No instances yet</h2>
<p className="text-ink-2">
Create a free cloud instance and we host it, with your licence applied
automatically. Or buy a self-hosted licence, install Vantage on your own
server, and link it here to get your licence file.
</p>
<Link
href="/instances/new"
className="justify-self-start rounded bg-accent px-4 py-2 text-[0.9rem] font-semibold text-accent-ink"
>
Create a free instance
</Link>
<div className="grid gap-3 sm:grid-cols-2">
<div className="grid content-start gap-2 rounded border border-rule p-4">
<h3 className="text-[1.05rem]">Cloud</h3>
<p className="text-[0.82rem] text-ink-2">We host it, on a subdomain of vantage.hostxtra.co.uk, with the licence applied for you.</p>
<div className="pt-1">
<LinkButton href="/purchase">Create a cloud instance</LinkButton>
</div>
</div>
<div className="grid content-start gap-2 rounded border border-rule p-4">
<h3 className="text-[1.05rem]">Self-hosted</h3>
<p className="text-[0.82rem] text-ink-2">You host it. Get the licence here, then paste your install&rsquo;s ID to bind it.</p>
<div className="pt-1">
<LinkButton variant="line" href="/purchase">
License my own install
</LinkButton>
</div>
</div>
</div>
<p className="text-[0.78rem] text-ink-3">The Free tier covers 5 servers and needs no card.</p>
</div>
) : (
<section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{data.instances.map((i) => (
<InstanceCard
key={i.instance_id}
instance={i}
license={byInstance.get(i.instance_id)}
/>
))}
</section>
)}
<PageFrame
aside={
<>
<RailCard title="Your team" count={people.data?.length}>
<ul className="grid gap-2">
{(people.data ?? []).slice(0, 5).map((p) => (
<li key={p.user_id} className="flex items-center justify-between gap-2.5 text-[0.82rem] text-ink-2">
<span className="truncate">{p.email}</span>
<span className="shrink-0 font-mono text-[0.64rem] uppercase tracking-[0.08em] text-ink-3">{p.account_role}</span>
</li>
))}
</ul>
{pending > 0 && (
<p className="border-t border-rule-soft pt-2 text-[0.78rem] text-warn">
{pending} {pending === 1 ? "invitation" : "invitations"} not accepted yet
</p>
)}
<Link href="/users" className="text-[0.82rem] font-semibold text-accent underline">
Manage people
</Link>
</RailCard>
{data.instances.length > 0 &&
!data.instances.some(
(i) => i.tier === "free" && i.status !== "cancelled" && i.status !== "deleted",
) && (
<Link
href="/instances/new"
className="justify-self-start text-[0.82rem] font-semibold text-accent underline"
>
Create a free instance
</Link>
)}
{/*
* Account-level facts only. Tier, limits and renewal date
* belong to a licence, and a licence belongs to one
* instance an account holding a Free cloud instance and
* a Professional self-hosted one has no single plan.
*/}
<RailCard title="Account">
<RailFacts
rows={[
{ label: "Billing contact", value: data.account.billing_email },
{ label: "Status", value: data.account.status },
{
label: "Customer since",
value: formatDate(data.account.created_at),
},
]}
/>
<Link href="/billing" className="text-[0.82rem] font-semibold text-accent underline">
Billing history
</Link>
</RailCard>
<RailCard title="Running Vantage yourself?">
<p className="text-[0.82rem] text-ink-2">Get a licence for your own install free or paid from the purchase page. It keeps its own users.</p>
<Link href="/purchase" className="text-[0.82rem] font-semibold text-accent underline">
Get a licence
</Link>
</RailCard>
</>
}
>
{/*
* First in the main column, not in the rail. This is the
* reason the page is open; the rail is for things that are
* merely true. It disappears entirely when there is nothing
* in it rather than saying "all clear", which is a line
* nobody needs to read twice a week.
*/}
{attention.length > 0 && (
<Panel title="Needs you" meta={`${attention.length} ${attention.length === 1 ? "item" : "items"}`} bodyless>
<ul className="grid">
{attention.map((a) => (
<li key={a.id} className="flex flex-wrap items-center justify-between gap-3 border-b border-rule-soft px-4 py-3 last:border-b-0">
<div className="grid min-w-0 gap-0.5">
<span className="flex items-center gap-2 text-[0.9rem] font-semibold">
{a.text}
{a.tag && <span className="font-mono text-[0.62rem] uppercase tracking-[0.1em] text-warn">{a.tag}</span>}
</span>
<span className="text-[0.8rem] text-ink-3">{a.note}</span>
</div>
<LinkButton href={a.href}>{a.action}</LinkButton>
</li>
))}
</ul>
</Panel>
)}
{live.map((i, n) => {
const lic = byInstance.get(i.instance_id);
const state = licenceState(lic?.expires_at, Boolean(lic));
return (
<InstanceRecord
key={i.instance_id}
instance={i}
license={lic}
// Open when it is the only one, or when it is the
// first thing that needs a decision. A saved toggle
// beats this from then on.
defaultOpen={live.length === 1 || (state !== "valid" && attention[0]?.id === i.instance_id) || (attention.length === 0 && n === 0)}
/>
);
})}
</PageFrame>
)}
</div>
);
}
@@ -0,0 +1,769 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { useMutation, useQuery } from "@tanstack/react-query";
import { ApiError, api, lineItemsFor, type CatalogueRow, type CheckoutOptions, type Deployment, type Plan, type Term, type Tier } from "@/lib/api";
import { initPaddle, previewPrices, type PricePreview } from "@/lib/paddle";
import { featureDesc, featureLabel } from "@/lib/features";
/* Tiers in the order a customer reads them, cheapest first. */
const TIER_ORDER: Tier[] = ["free", "professional", "enterprise"];
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/* Feature wording lives in lib/features.ts, shared with the staff
* configurator. It was duplicated here and there, and the two copies had
* already drifted. */
interface Choice {
tier: Tier;
term: Term;
servers: number;
features: string[];
}
/* What a plan offers a given feature: included in the base, a paid add-on, or
* absent. Drives both the tier cards and the configurator toggles. */
type FeatureState = "included" | "addon" | "absent";
function featureStateFor(plan: Plan | undefined, rows: CatalogueRow[], env: string, term: Term, key: string): FeatureState {
if (plan?.base_features.includes(key)) return "included";
const row = rows.find((r) => r.kind === "feature" && r.feature_key === key);
const priced = Boolean(row?.price_ids?.[env]?.[term]);
return priced ? "addon" : "absent";
}
export function PurchaseForm() {
const router = useRouter();
const account = useQuery({ queryKey: ["account"], queryFn: api.account });
const optionsQ = useQuery({ queryKey: ["checkout-options"], queryFn: api.checkoutOptions });
const [dep, setDep] = useState<Deployment>("cloud");
const [choice, setChoice] = useState<Choice>({
tier: "professional",
term: "annual",
servers: 3,
features: [],
});
const [name, setName] = useState("");
const [error, setError] = useState<string | null>(null);
// Follow-up phase after a checkout has been started.
const [pending, setPending] = useState<null | {
instanceId: string;
deployment: Deployment;
}>(null);
const [uuid, setUuid] = useState("");
const options = optionsQ.data;
const accountId = account.data?.account.account_id ?? "";
// Distinct feature keys offered on this deployment, in a stable order.
const featureKeys = useMemo(() => {
if (!options) return [] as string[];
const keys = new Set<string>();
for (const r of options.catalogue) {
if (r.deployment === dep && r.kind === "feature" && r.feature_key) {
keys.add(r.feature_key);
}
}
return [...keys];
}, [options, dep]);
const activePlans = useMemo(() => (options?.plans ?? []).filter((p) => p.deployment === dep && p.active).sort((a, b) => TIER_ORDER.indexOf(a.tier) - TIER_ORDER.indexOf(b.tier)), [options, dep]);
const plan = activePlans.find((p) => p.tier === choice.tier);
const baseServers = plan?.base_limits.max_servers ?? 0;
const unlimited = baseServers === -1;
const rows = useMemo(() => (options?.catalogue ?? []).filter((r) => r.deployment === dep && r.tier === choice.tier), [options, dep, choice.tier]);
// Real line items for the current configuration the same builder the
// checkout uses, so the summary can never disagree with the overlay.
const items = useMemo(() => (options ? lineItemsFor(options, choice, dep) : []), [options, choice, dep]);
// Real, localised prices from Paddle for those items.
const [receiptPrice, setReceiptPrice] = useState<PricePreview | null>(null);
useEffect(() => {
let live = true;
previewPrices(items).then((p) => {
if (live) setReceiptPrice(p);
});
return () => {
live = false;
};
}, [items]);
// A headline "base" price per tier, all previewed in one call.
const [basePrices, setBasePrices] = useState<Record<string, string>>({});
useEffect(() => {
if (!options) return;
const baseItems: { priceId: string; quantity: number; tier: Tier }[] = [];
for (const p of activePlans) {
const row = options.catalogue.find((r) => r.deployment === dep && r.tier === p.tier && r.kind === "base");
const id = row?.price_ids?.[options.env]?.[choice.term];
if (id) baseItems.push({ priceId: id, quantity: 1, tier: p.tier });
}
let live = true;
previewPrices(baseItems.map(({ priceId, quantity }) => ({ priceId, quantity }))).then((p) => {
if (!live) return;
const next: Record<string, string> = {};
if (p) {
for (const bi of baseItems) {
const line = p.lines[bi.priceId];
if (line) next[bi.tier] = line.total;
}
}
setBasePrices(next);
});
return () => {
live = false;
};
}, [options, dep, choice.term, activePlans]);
// --- actions -----------------------------------------------------------
const createFree = useMutation({
mutationFn: () => api.createInstance(name.trim()),
onSuccess: () => router.push("/"),
onError: (e) => setError(e instanceof ApiError ? e.message : "Could not create the instance."),
});
// Self-hosted Free binds to the install's own UUID: register the instance,
// then issue its Free licence in one action.
const createSelfHostedFree = useMutation({
mutationFn: async () => {
const inst = await api.link(uuid.trim(), name.trim());
await api.claimFree(inst.instance_id);
return inst.instance_id;
},
onSuccess: (id) => router.push(`/instances/${id}`),
onError: (e) => setError(e instanceof ApiError ? e.message : "Could not create the licence."),
});
// Self-hosted checkout names the install's REAL UUID, so the instance is
// linked (or an already-owned one reused) before Paddle opens. The webhook
// then issues straight onto it — there is no placeholder to claim afterwards.
const startCheckout = useMutation({
mutationFn: async () => {
const trimmed = name.trim();
const r = dep === "cloud" ? await api.createCloudCheckout(trimmed) : await api.createSelfHostedCheckout(uuid.trim(), trimmed);
return r.instance_id;
},
onSuccess: async (instanceId) => {
setPending({ instanceId, deployment: dep });
const paddle = await initPaddle();
paddle?.Checkout.open({
items: items.map((i) => ({ priceId: i.priceId, quantity: i.quantity })),
customData: { account_id: accountId, instance_id: instanceId },
});
},
onError: (e) => setError(e instanceof ApiError ? e.message : "Could not start checkout."),
});
if (optionsQ.isLoading || account.isLoading) {
return <p className="text-ink-3">Loading plans</p>;
}
if (!options) {
return <p className="text-ink-2">Plans are unavailable right now. Try again shortly.</p>;
}
const selfHostedFree = dep === "self_hosted" && choice.tier === "free";
const cloudFree = dep === "cloud" && choice.tier === "free";
const paid = choice.tier !== "free";
return (
<div className="grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_340px]">
{/* ---- main column ---- */}
<div className="grid min-w-0 gap-6">
<Block n={1} label="Deployment">
<Seg
value={dep}
onChange={(v) => {
const next = v as Deployment;
setDep(next);
// Self-hosted sells annual only; clamp the term.
setChoice((c) => ({
...c,
term: next === "self_hosted" ? "annual" : c.term,
}));
}}
options={[
{
value: "cloud",
icon: cloudIcon,
title: "Cloud",
sub: "We host and manage it · monthly or annual",
},
{
value: "self_hosted",
icon: serverIcon,
title: "Self-hosted",
sub: "Runs on your own servers · annual only",
},
]}
/>
</Block>
{dep === "cloud" && (
<Block n={2} label="Billing">
<Seg
value={choice.term}
onChange={(v) => setChoice((c) => ({ ...c, term: v as Term }))}
options={[
{
value: "monthly",
icon: calendarIcon,
title: "Monthly",
sub: "Pay as you go · cancel anytime",
},
{
value: "annual",
icon: annualIcon,
title: "Annual",
sub: "2 months free vs monthly",
},
]}
/>
</Block>
)}
<Block n={dep === "cloud" ? 3 : 2} label="Plan">
<div className="grid gap-3 sm:grid-cols-3">
{activePlans.map((p) => (
<TierCard
key={p.tier}
plan={p}
selected={p.tier === choice.tier}
headline={p.tier === "free" ? "£0" : basePrices[p.tier]}
cycleLabel={cycleShort(dep, choice.term)}
featureKeys={featureKeys}
catalogue={options.catalogue.filter((r) => r.deployment === dep && r.tier === p.tier)}
env={options.env}
term={choice.term}
onSelect={() =>
setChoice((c) => ({
...c,
tier: p.tier,
// Moving tier moves the floor; clamp up.
servers: Math.max(c.servers, p.base_limits.max_servers === -1 ? c.servers : p.base_limits.max_servers),
// Drop add-ons the new tier does not sell.
features: c.features.filter((k) => {
const st = featureStateFor(
p,
options.catalogue.filter((r) => r.deployment === dep && r.tier === p.tier),
options.env,
c.term,
k,
);
return st === "addon";
}),
}))
}
/>
))}
</div>
</Block>
{paid && (
<Block n={dep === "cloud" ? 4 : 3} label="Configure">
<div className="rounded border border-rule bg-panel p-4">
{/* servers */}
<Row title="Managed servers" desc={unlimited ? "Unlimited servers included in this plan" : `${baseServers} included`}>
{unlimited ? (
<span className="text-[0.72rem] font-semibold uppercase tracking-[0.06em] text-valid">Unlimited</span>
) : (
<Stepper value={choice.servers} min={baseServers} max={500} onChange={(servers) => setChoice((c) => ({ ...c, servers }))} />
)}
</Row>
{/* features */}
{featureKeys.map((key) => {
const st = featureStateFor(plan, rows, options.env, choice.term, key);
return (
<Row key={key} title={featureLabel(key)} desc={featureDesc(key)} dim={st === "absent"}>
{st === "included" ? (
<span className="text-[0.72rem] font-semibold uppercase tracking-[0.06em] text-valid">Included</span>
) : st === "absent" ? (
<span className="font-mono text-[0.76rem] text-ink-3">Not in this plan</span>
) : (
<Toggle
checked={choice.features.includes(key)}
onChange={(on) =>
setChoice((c) => ({
...c,
features: on ? [...c.features, key] : c.features.filter((f) => f !== key),
}))
}
/>
)}
</Row>
);
})}
</div>
</Block>
)}
{dep === "self_hosted" && (
<Block n={paid ? 4 : 3} label="Your install">
<div className="grid gap-3 rounded border border-rule bg-panel p-4">
<p className="text-[0.86rem] text-ink-2">
{paid
? "Every licence binds to one install, so stand your control plane up first and paste the instance ID it reports. We attach it to your account now and the licence lands the moment payment clears. Already have an instance here? Paste its ID to upgrade it."
: "Install Vantage on your own server first, then paste the instance ID it reports. We register it and issue your Free licence — nothing to pay."}
</p>
<label className="grid gap-1">
<span className="text-[0.72rem] font-semibold uppercase tracking-[0.08em] text-ink-3">Instance ID</span>
<input
value={uuid}
onChange={(e) => setUuid(e.target.value)}
placeholder="00000000-0000-0000-0000-000000000000"
className="rounded border border-rule bg-panel px-2.5 py-2 font-mono text-[0.82rem] text-ink placeholder:text-ink-3"
/>
<span className="text-[0.72rem] text-ink-3">Find this on your install&rsquo;s Settings Licence page, or the setup screen just after first sign-in.</span>
</label>
</div>
</Block>
)}
</div>
{/* ---- receipt rail ---- */}
<aside className="lg:sticky lg:top-5">
<div className="overflow-hidden rounded-[14px] border border-rule bg-panel shadow-[var(--shadow)]">
<div className="flex items-center justify-between border-b border-rule-soft px-4 py-3.5">
<h3 className="text-[0.95rem] font-semibold">Order summary</h3>
<span className="rounded border border-rule px-1.5 py-0.5 font-mono text-[0.62rem] uppercase tracking-[0.07em] text-ink-3">{dep === "cloud" ? "Cloud" : "Self-hosted"}</span>
</div>
<Receipt options={options} dep={dep} choice={choice} plan={plan} items={items} price={receiptPrice} />
{/* name + CTA */}
<div className="grid gap-3 border-t border-rule px-4 py-4">
{!pending && (
<label className="grid gap-1">
<span className="text-[0.72rem] font-semibold uppercase tracking-[0.08em] text-ink-3">Instance name</span>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Northgate Systems"
className="rounded border border-rule bg-panel px-2.5 py-2 text-[0.9rem] text-ink placeholder:text-ink-3"
/>
</label>
)}
{error && <p className="text-[0.82rem] text-expired">{error}</p>}
{/* Phase A: choose an action for the configuration. */}
{!pending &&
(selfHostedFree ? (
<Cta
label={createSelfHostedFree.isPending ? "Creating…" : "Create licence"}
variant="line"
disabled={!UUID_RE.test(uuid.trim()) || createSelfHostedFree.isPending}
onClick={() => {
setError(null);
createSelfHostedFree.mutate();
}}
/>
) : cloudFree ? (
<Cta
label={createFree.isPending ? "Creating…" : "Create free instance"}
variant="line"
disabled={!name.trim() || createFree.isPending}
onClick={() => {
setError(null);
createFree.mutate();
}}
/>
) : (
<Cta
label={startCheckout.isPending ? "Starting…" : "Continue to payment"}
disabled={!name.trim() || items.length === 0 || !accountId || startCheckout.isPending || (dep === "self_hosted" && !UUID_RE.test(uuid.trim()))}
onClick={() => {
setError(null);
startCheckout.mutate();
}}
/>
))}
{/* Phase B: after the checkout has been opened. */}
{pending && (
<div className="grid gap-2 border-t border-rule-soft pt-3">
<p className="text-[0.8rem] text-ink-2">
{pending.deployment === "cloud"
? "Your instance is being set up. Its licence appears the moment payment clears — no further steps."
: "Your install is attached to this account. Its licence appears the moment payment clears — no further steps."}
</p>
<Link href={`/instances/${pending.instanceId}`} className="font-semibold text-accent underline">
Go to your instance
</Link>
</div>
)}
</div>
<div className="flex items-start gap-2 border-t border-rule-soft px-4 py-3 text-[0.72rem] text-ink-3">
<LockIcon />
<span>{paid ? "Secure checkout by Paddle, our reseller of record. VAT is added at checkout where applicable." : "No payment details required for the Free plan."}</span>
</div>
</div>
</aside>
</div>
);
}
// ---------------------------------------------------------------------------
// Presentational pieces
// ---------------------------------------------------------------------------
function cycleShort(dep: Deployment, term: Term) {
return dep === "cloud" ? (term === "annual" ? "/yr" : "/mo") : "/yr";
}
function Block({ n, label, children }: { n: number; label: string; children: React.ReactNode }) {
return (
<section className="grid gap-2.5">
<h2 className="flex items-center gap-2 text-[0.72rem] font-bold uppercase tracking-[0.1em] text-ink-3">
<span className="font-mono text-accent">{n}</span>
{label}
</h2>
{children}
</section>
);
}
interface SegOption {
value: string;
icon: React.ReactNode;
title: string;
sub: string;
}
function Seg({ value, onChange, options }: { value: string; onChange: (v: string) => void; options: SegOption[] }) {
return (
<div className="flex gap-1 rounded-[9px] border border-rule bg-panel-2 p-1">
{options.map((o) => {
const on = o.value === value;
return (
<button
key={o.value}
type="button"
aria-pressed={on}
onClick={() => onChange(o.value)}
className={`flex flex-1 items-center gap-3 rounded-[7px] px-4 py-3 text-left transition-colors ${on ? "bg-panel text-ink shadow-[var(--shadow)]" : "text-ink-2"}`}
>
<span
className={`grid h-[34px] w-[34px] flex-none place-items-center rounded-lg border ${
on ? "border-accent/40 bg-accent-wash text-accent" : "border-rule bg-panel text-ink-3"
}`}
>
{o.icon}
</span>
<span className="flex flex-col leading-tight">
<span className="text-[0.92rem] font-bold">{o.title}</span>
<span className={`text-[0.72rem] font-medium ${on ? "text-accent" : "text-ink-3"}`}>{o.sub}</span>
</span>
<span className={`relative ml-auto h-[18px] w-[18px] flex-none rounded-full border-2 ${on ? "border-accent bg-accent" : "border-rule"}`}>
{on && <span className="absolute inset-[3px] rounded-full bg-accent-ink" />}
</span>
</button>
);
})}
</div>
);
}
function TierCard({
plan,
selected,
headline,
cycleLabel,
featureKeys,
catalogue,
env,
term,
onSelect,
}: {
plan: Plan;
selected: boolean;
headline?: string;
cycleLabel: string;
featureKeys: string[];
catalogue: CatalogueRow[];
env: string;
term: Term;
onSelect: () => void;
}) {
const base = plan.base_limits.max_servers;
const servers = base === -1 ? "Unlimited servers" : `${base} server${base === 1 ? "" : "s"} included`;
return (
<button
type="button"
aria-pressed={selected}
onClick={onSelect}
className={`relative flex flex-col gap-3 rounded-xl border bg-panel p-4 text-left transition-[border-color,box-shadow] ${
selected ? "border-accent shadow-[0_0_0_1px_var(--accent)]" : "border-rule hover:border-accent/50"
}`}
>
{plan.tier === "professional" && (
<span className="absolute -top-2 right-3 rounded-full bg-accent px-2 py-0.5 text-[0.6rem] font-bold uppercase tracking-[0.08em] text-accent-ink">Most popular</span>
)}
<span className="flex items-center justify-between gap-2">
<span className="text-[1.05rem] font-extrabold tracking-[-0.02em]">{plan.name}</span>
<span className={`relative h-4 w-4 flex-none rounded-full border-2 ${selected ? "border-accent bg-accent" : "border-rule"}`}>
{selected && <span className="absolute inset-[3px] rounded-full bg-accent-ink" />}
</span>
</span>
<span className="flex items-baseline gap-1">
<span className="text-[1.5rem] font-extrabold tracking-[-0.03em] tabular-nums">{headline ?? "—"}</span>
<span className="text-[0.72rem] text-ink-3">{plan.tier === "free" ? "forever" : cycleLabel}</span>
</span>
<ul className="grid gap-1.5 text-[0.8rem] text-ink-2">
<FeatureLine on>{servers}</FeatureLine>
{featureKeys.map((key) => {
const st = featureStateFor(plan, catalogue, env, term, key);
return (
<FeatureLine key={key} on={st !== "absent"}>
{featureLabel(key)}
{st === "included" ? " included" : st === "addon" ? " add-on" : " not available"}
</FeatureLine>
);
})}
<FeatureLine on>{supportLabel(plan.support_level)} support</FeatureLine>
</ul>
</button>
);
}
function supportLabel(level: string) {
switch (level) {
case "community":
return "Community";
case "email_24_5":
return "Email, 24/5";
case "email_call_24_7":
return "Email + call, 24/7";
default:
return level;
}
}
function FeatureLine({ on, children }: { on: boolean; children: React.ReactNode }) {
return (
<li className={`flex items-start gap-2 ${on ? "" : "text-ink-3"}`}>
<span className={`mt-0.5 flex-none ${on ? "text-valid" : "text-ink-3"}`} aria-hidden>
{on ? (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 6 9 17l-5-5" />
</svg>
) : (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round">
<path d="M5 12h14" />
</svg>
)}
</span>
<span>{children}</span>
</li>
);
}
function Row({ title, desc, dim, children }: { title: string; desc: string; dim?: boolean; children: React.ReactNode }) {
return (
<div className={`flex items-center justify-between gap-4 border-b border-rule-soft py-3.5 first:pt-0 last:border-0 last:pb-0 ${dim ? "opacity-55" : ""}`}>
<div className="min-w-0">
<h4 className="text-[0.9rem] font-semibold">{title}</h4>
{desc && <p className="text-[0.78rem] text-ink-3">{desc}</p>}
</div>
<div className="flex-none">{children}</div>
</div>
);
}
function Stepper({ value, min, max, onChange }: { value: number; min: number; max: number; onChange: (v: number) => void }) {
const clamp = (v: number) => Math.min(max, Math.max(min, v));
return (
<div className="inline-flex items-center overflow-hidden rounded-lg border border-rule">
<button
type="button"
aria-label="Fewer servers"
disabled={value <= min}
onClick={() => onChange(clamp(value - 1))}
className="h-9 w-9 bg-panel-2 text-lg leading-none text-ink hover:bg-accent-wash hover:text-accent disabled:opacity-35"
>
</button>
<input
value={value}
inputMode="numeric"
aria-label="Server count"
onChange={(e) => onChange(clamp(parseInt(e.target.value) || min))}
className="h-9 w-14 border-x border-rule bg-panel text-center text-[0.9rem] font-bold tabular-nums text-ink"
/>
<button
type="button"
aria-label="More servers"
disabled={value >= max}
onClick={() => onChange(clamp(value + 1))}
className="h-9 w-9 bg-panel-2 text-lg leading-none text-ink hover:bg-accent-wash hover:text-accent disabled:opacity-35"
>
+
</button>
</div>
);
}
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={`relative h-6 w-[42px] flex-none rounded-full transition-colors ${checked ? "bg-accent" : "bg-rule"}`}
>
<span className={`absolute top-[3px] h-[18px] w-[18px] rounded-full bg-white shadow transition-[left] ${checked ? "left-[21px]" : "left-[3px]"}`} />
</button>
);
}
function Receipt({
options,
dep,
choice,
plan,
items,
price,
}: {
options: CheckoutOptions;
dep: Deployment;
choice: Choice;
plan: Plan | undefined;
items: { priceId: string; quantity: number }[];
price: PricePreview | null;
}) {
if (choice.tier === "free") {
return (
<div className="px-4">
<div className="flex items-center justify-between gap-3 py-3 text-[0.85rem]">
<span className="text-ink-2">
{plan?.name ?? "Free"} plan
<small className="block text-[0.72rem] text-ink-3">{plan?.base_limits.max_servers ?? 1} server · community support</small>
</span>
<span className="font-mono font-semibold tabular-nums text-valid">£0</span>
</div>
</div>
);
}
// Label each real line item from the catalogue, and price it from Paddle.
const base = plan?.base_limits.max_servers ?? 0;
const extra = base === -1 ? 0 : Math.max(0, choice.servers - base);
const rows = options.catalogue.filter((r) => r.deployment === dep && r.tier === choice.tier);
const idFor = (predicate: (r: CatalogueRow) => boolean) => {
const row = rows.find(predicate);
return row?.price_ids?.[options.env]?.[choice.term] ?? "";
};
const amount = (priceId: string) => price?.lines[priceId]?.total ?? null;
const lines: { label: string; sub?: string; value: string | null }[] = [];
const baseId = idFor((r) => r.kind === "base");
lines.push({
label: `${plan?.name ?? ""} base`,
sub: base === -1 ? "unlimited servers" : `${base} servers included`,
value: amount(baseId),
});
if (extra > 0) {
lines.push({
label: "Extra servers",
sub: `${extra} × per server`,
value: amount(idFor((r) => r.kind === "limit" && r.limit_key === "max_servers")),
});
}
for (const key of choice.features) {
const id = idFor((r) => r.kind === "feature" && r.feature_key === key);
if (id) lines.push({ label: featureLabel(key), sub: "add-on", value: amount(id) });
}
const priced = price !== null;
return (
<div className="px-4">
<div className="grid">
{lines.map((l, i) => (
<div key={i} className="flex justify-between gap-3 border-b border-dashed border-rule-soft py-2.5 text-[0.85rem] last:border-0">
<span className="text-ink-2">
{l.label}
{l.sub && <small className="block text-[0.72rem] text-ink-3">{l.sub}</small>}
</span>
<span className="font-mono font-semibold tabular-nums">{l.value ?? "—"}</span>
</div>
))}
</div>
<div className="mt-2 flex items-baseline justify-between border-t border-rule pt-3">
<span className="text-[0.85rem]">Total</span>
<span className="text-[1.4rem] font-extrabold tabular-nums">{priced && price?.total ? price.total : "—"}</span>
</div>
<p className="pb-3 pt-0.5 text-[0.72rem] text-ink-3">
{priced
? dep === "cloud"
? choice.term === "annual"
? "per year, billed annually"
: "per month, billed monthly"
: "per year, billed annually"
: items.length > 0
? "Final price shown at checkout."
: ""}
</p>
</div>
);
}
function Cta({ label, onClick, disabled, variant = "solid" }: { label: string; onClick: () => void; disabled?: boolean; variant?: "solid" | "line" }) {
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
className={`rounded-[9px] px-3 py-3 text-[0.9rem] font-bold transition-[filter] hover:brightness-[1.06] disabled:opacity-40 disabled:hover:brightness-100 ${
variant === "solid" ? "bg-accent text-accent-ink" : "border border-accent bg-panel text-accent"
}`}
>
{label}
</button>
);
}
// ---------------------------------------------------------------------------
// Icons
// ---------------------------------------------------------------------------
const cloudIcon = (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17.5 19a4.5 4.5 0 0 0 .5-9 6 6 0 0 0-11.6-1.5A4 4 0 0 0 6 19z" />
</svg>
);
const serverIcon = (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="3" width="20" height="6" rx="1" />
<rect x="2" y="9" width="20" height="6" rx="1" />
<path d="M6 6h.01M6 12h.01" />
</svg>
);
const calendarIcon = (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" />
<path d="M3 10h18M8 2v4M16 2v4" />
</svg>
);
const annualIcon = (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
</svg>
);
function LockIcon() {
return (
<svg className="mt-px flex-none" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="11" width="18" height="11" rx="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
);
}
@@ -0,0 +1,18 @@
import type { Metadata } from "next";
import { PurchaseForm } from "./PurchaseForm";
import { PageHeader } from "@/components/PageHeader";
export const metadata: Metadata = { title: "Buy a plan" };
export default function PurchasePage() {
return (
<div className="grid gap-6">
<PageHeader
back={{ href: "/", label: "Overview" }}
title="Choose your plan"
subtitle="Configure the instance, see exactly what you'll be charged, then pay. Nothing is billed until you confirm at checkout."
/>
<PurchaseForm />
</div>
);
}
+7 -8
View File
@@ -5,6 +5,7 @@ import { useState } from "react";
import { ApiError, api } from "@/lib/api";
import { Button } from "@/components/Button";
import { Field } from "@/components/Field";
import { PageHeader } from "@/components/PageHeader";
export default function SettingsPage() {
const [current, setCurrent] = useState("");
@@ -28,14 +29,12 @@ export default function SettingsPage() {
});
return (
<div className="grid gap-8">
<header className="grid gap-2">
<h1 className="text-3xl">Settings</h1>
<p className="text-ink-2">
Your password signs you in here and into every Vantage instance you belong to.
Changing it changes all of them.
</p>
</header>
<div className="grid gap-6">
<PageHeader
back={{ href: "/", label: "Overview" }}
title="Settings"
subtitle="Your password signs you in here and into every Vantage instance you belong to. Changing it changes all of them."
/>
<form
className="grid max-w-md gap-4 rounded border border-rule bg-panel p-5"
+178 -114
View File
@@ -5,17 +5,26 @@ import { useState } from "react";
import { API_BASE, ApiError, NotConnected, api, type AccountRole } from "@/lib/api";
import { useSession } from "@/lib/session";
import { NotConnectedPanel } from "@/components/NotConnected";
import { Button } from "@/components/Button";
import { Button, controlClass } from "@/components/Button";
import { Field } from "@/components/Field";
import { PageFrame, RailCard } from "@/components/PageFrame";
import { formatDate } from "@/lib/format";
const ROLES: AccountRole[] = ["owner", "admin", "member"];
const WHAT_ROLES_DO: [AccountRole, string][] = [
["owner", "Everything, including billing."],
["admin", "Invite people, create instances, grant access. No billing."],
["member", "Sign in to the instances they are given."],
];
export function InvitePanel() {
const qc = useQueryClient();
const { session } = useSession();
const [email, setEmail] = useState("");
const [role, setRole] = useState<AccountRole>("member");
const [error, setError] = useState<string | null>(null);
const [confirming, setConfirming] = useState<string | null>(null);
const users = useQuery({ queryKey: ["account-users"], queryFn: api.accountUsers });
const refresh = () => qc.invalidateQueries({ queryKey: ["account-users"] });
@@ -38,8 +47,14 @@ export function InvitePanel() {
});
const remove = useMutation({
mutationFn: (id: string) => api.removeAccountUser(id),
onSuccess: refresh,
onError: fail,
onSuccess: () => {
setConfirming(null);
refresh();
},
onError: (e) => {
setConfirming(null);
fail(e);
},
});
if (users.error instanceof NotConnected) return <NotConnectedPanel url={API_BASE} />;
@@ -49,125 +64,174 @@ export function InvitePanel() {
const assignable = myRole === "owner" ? ROLES : ROLES.filter((r) => r !== "owner");
return (
<div className="grid gap-6">
<PageFrame
aside={
<>
{canManage && (
<RailCard title="Invite someone">
<form
className="grid gap-3"
onSubmit={(e) => {
e.preventDefault();
setError(null);
if (email.trim()) invite.mutate();
}}
>
<Field
label="Email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
hint="They choose their own password from the emailed link. Nothing happens until they open it."
/>
<label className="grid gap-1.5">
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
Account role
</span>
<select value={role} onChange={(e) => setRole(e.target.value as AccountRole)} className={controlClass()}>
{assignable.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
</label>
<Button type="submit" disabled={invite.isPending || !email.trim()}>
{invite.isPending ? "Sending…" : "Send invitation"}
</Button>
</form>
</RailCard>
)}
<RailCard title="What the roles do">
<dl className="grid gap-2">
{WHAT_ROLES_DO.map(([r, what]) => (
<div key={r} className="grid gap-0.5">
<dt className="font-mono text-[0.68rem] uppercase tracking-[0.08em] text-ink">
{r}
</dt>
<dd className="m-0 text-[0.8rem] text-ink-2">{what}</dd>
</div>
))}
</dl>
<p className="border-t border-rule-soft pt-2 text-[0.8rem] text-ink-2">
An account role is not access to an instance. Give someone that on the
instance itself.
</p>
</RailCard>
</>
}
>
{error && (
<p className="rounded border border-expired bg-panel p-3 text-[0.9rem] text-expired">
{error}
</p>
)}
<table className="w-full border-collapse text-left text-[0.9rem]">
<thead>
<tr className="border-b border-rule font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
<th className="py-2">Email</th>
<th className="py-2">Account role</th>
<th className="py-2">Status</th>
<th className="py-2" />
</tr>
</thead>
<tbody>
{(users.data ?? []).map((u) => {
const isSelf = u.email === session?.email;
return (
<tr key={u.user_id} className="border-b border-rule-soft">
<td className="py-2.5">
{u.email}
{isSelf && <span className="ml-2 text-ink-3">(you)</span>}
</td>
<td className="py-2.5">
{canManage && !isSelf ? (
<select
value={u.account_role}
onChange={(e) =>
setRoleFor.mutate({
id: u.user_id,
role: e.target.value as AccountRole,
})
}
className="rounded border border-rule bg-panel-2 px-2 py-1 font-mono text-[0.82rem] text-ink"
>
{assignable.map((r) => (
<option key={r} value={r}>
{r}
</option>
<div className="overflow-x-auto rounded border border-rule bg-panel">
<table className="w-full border-collapse text-left text-[0.9rem]">
<thead>
<tr className="border-b border-rule bg-panel-2 font-mono text-[0.68rem] uppercase tracking-[0.1em] text-ink-3">
<th className="px-4 py-2.5 font-normal">Email</th>
<th className="px-4 py-2.5 font-normal">Account role</th>
<th className="px-4 py-2.5 font-normal">Status</th>
<th className="px-4 py-2.5" />
</tr>
</thead>
<tbody>
{(users.data ?? []).map((u) => {
const isSelf = u.email === session?.email;
return (
<tr key={u.user_id} className="border-b border-rule-soft last:border-0">
<td className="px-4 py-3">
{u.email}
{isSelf && <span className="ml-2 text-ink-3">(you)</span>}
</td>
<td className="px-4 py-3">
{canManage && !isSelf ? (
<select
value={u.account_role}
onChange={(e) =>
setRoleFor.mutate({
id: u.user_id,
role: e.target.value as AccountRole,
})
}
className="rounded border border-rule bg-panel-2 px-2 py-1 font-mono text-[0.82rem] text-ink"
>
{assignable.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
) : (
<span className="font-mono text-[0.82rem]">
{u.account_role}
</span>
)}
</td>
<td className="px-4 py-3 text-ink-2">
{u.verified_at
? `Active since ${formatDate(u.verified_at)}`
: "Invitation pending"}
</td>
<td className="px-4 py-3 text-right">
{canManage &&
!isSelf &&
/*
* Inline rather than window.confirm(): removing
* someone here revokes them from every instance
* on the account, which is more than the word
* "Remove" beside one row implies, and the
* browser dialog cannot show the consequence
* where the eye already is.
*/
(confirming === u.user_id ? (
<span className="inline-flex flex-wrap items-center justify-end gap-2">
<span className="text-[0.82rem] text-ink-2">
Removes access to every instance.
</span>
<button
type="button"
className="text-[0.82rem] font-semibold text-expired underline disabled:opacity-50"
disabled={remove.isPending}
onClick={() => remove.mutate(u.user_id)}
>
{remove.isPending ? "Removing…" : "Remove"}
</button>
<button
type="button"
className="text-[0.82rem] text-ink-2 underline"
onClick={() => setConfirming(null)}
>
Keep
</button>
</span>
) : (
<button
type="button"
className="text-[0.82rem] font-semibold text-expired underline"
onClick={() => setConfirming(u.user_id)}
>
Remove<span className="sr-only"> {u.email}</span>
</button>
))}
</select>
) : (
<span className="font-mono text-[0.82rem]">
{u.account_role}
</span>
)}
</td>
<td className="py-2.5 text-ink-2">
{u.verified_at ? "Active" : "Invitation pending"}
</td>
<td className="py-2.5 text-right">
{canManage && !isSelf && (
<button
type="button"
className="text-[0.82rem] font-semibold text-expired underline"
onClick={() => {
if (
confirm(
`Remove ${u.email}? They lose access to every instance on this account.`,
)
)
remove.mutate(u.user_id);
}}
>
Remove
</button>
)}
</td>
</tr>
);
})}
{users.data?.length === 0 && (
<tr>
<td colSpan={4} className="px-4 py-6 text-ink-3">
Nobody yet.
</td>
</tr>
);
})}
</tbody>
</table>
{canManage && (
<form
className="grid max-w-md gap-4 rounded border border-rule bg-panel p-5"
onSubmit={(e) => {
e.preventDefault();
setError(null);
if (email.trim()) invite.mutate();
}}
>
<h2 className="text-xl">Invite someone</h2>
<Field
label="Email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
hint="They choose their own password from the emailed link. Nothing happens until they open it."
/>
<label className="grid max-w-md gap-1.5">
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
Account role
</span>
<select
value={role}
onChange={(e) => setRole(e.target.value as AccountRole)}
className="rounded border border-rule bg-panel-2 px-2.5 py-2 font-mono text-ink"
>
{assignable.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
</label>
<p className="text-[0.82rem] text-ink-2">
An account role is not access to an instance. Give them that on the
instance itself.
</p>
<Button type="submit" disabled={invite.isPending || !email.trim()}>
{invite.isPending ? "Sending…" : "Send invitation"}
</Button>
</form>
)}
</div>
)}
</tbody>
</table>
</div>
</PageFrame>
);
}
+8 -8
View File
@@ -1,15 +1,15 @@
"use client";
import { InvitePanel } from "./InvitePanel";
import { PageHeader } from "@/components/PageHeader";
export default function UsersPage() {
return (
<div className="grid gap-8">
<header className="grid gap-2">
<h1 className="text-3xl">People</h1>
<p className="text-ink-2">
Everyone on this account. Owners and admins can invite people and grant them
access to instances; billing stays with owners.
</p>
</header>
<div className="grid gap-6">
<PageHeader
title="People"
subtitle="Everyone on this account. Owners and admins can invite people and grant them access to instances; billing stays with owners."
/>
<InvitePanel />
</div>
);
@@ -4,8 +4,10 @@ import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { useState } from "react";
import { api } from "@/lib/api";
import { Field } from "@/components/Field";
import { formatDate } from "@/lib/format";
import { EmptyState, Panel } from "@/components/Panel";
import { controlClass } from "@/components/Button";
import { Sub, TBody, TD, TH, THead, TR, Table } from "@/components/Table";
export function AccountSearch() {
const [q, setQ] = useState("");
@@ -14,56 +16,65 @@ export function AccountSearch() {
queryFn: () => api.staff.accounts(q || undefined),
});
const rows = data ?? [];
return (
<div className="grid gap-4">
<Field
label="Search"
value={q}
onChange={(e) => setQ(e.target.value)}
hint="Name, email, Paddle customer ID, or an instance UUID."
/>
<div className="overflow-x-auto rounded border border-rule bg-panel">
<table className="w-full border-collapse text-left">
<thead>
<tr className="border-b border-rule bg-panel-2 font-mono text-[0.72rem] uppercase tracking-[0.08em] text-ink-3">
<th className="px-4 py-2.5">Account</th>
<th className="px-4 py-2.5">Billing email</th>
<th className="px-4 py-2.5">Status</th>
<th className="px-4 py-2.5">Created</th>
</tr>
</thead>
<tbody>
{(data ?? []).map((a) => (
<tr
key={a.account_id}
className="border-b border-rule-soft last:border-0"
>
<td className="px-4 py-3">
<Link
href={`/staff/accounts/${a.account_id}`}
className="text-accent underline"
>
<Panel>
<label className="grid gap-1.5">
<span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">Search</span>
<input
type="search"
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Name, email, ctm_… or an instance UUID"
className={controlClass()}
/>
</label>
</Panel>
<Panel bodyless>
<Table>
<THead>
<TR className="hover:bg-transparent">
<TH>Account</TH>
<TH>Billing email</TH>
<TH>Status</TH>
<TH>Created</TH>
<TH />
</TR>
</THead>
<TBody>
{rows.map((a) => (
<TR key={a.account_id}>
<TD>
<Link href={`/staff/accounts/${a.account_id}`} className="font-semibold text-accent no-underline hover:underline">
{a.name}
</Link>
</td>
<td className="px-4 py-3 font-mono text-[0.82rem]">
{a.billing_email}
</td>
<td className="px-4 py-3">{a.status}</td>
<td className="px-4 py-3 font-mono tabular-nums">
{formatDate(a.created_at)}
</td>
</tr>
<Sub>
<span className="font-mono">{a.account_id}</span>
</Sub>
</TD>
<TD className="font-mono text-[0.82rem] text-ink-2">{a.billing_email}</TD>
<TD className="text-ink-2">{a.status}</TD>
<TD className="font-mono tabular-nums text-ink-2">{formatDate(a.created_at)}</TD>
<TD numeric>
<Link href={`/staff/accounts/${a.account_id}`} className="font-mono text-[0.7rem] uppercase tracking-[0.1em] text-ink-3 no-underline hover:text-accent">
Open
</Link>
</TD>
</TR>
))}
</tbody>
</table>
{!isFetching && (data ?? []).length === 0 && (
<p className="px-4 py-6 text-ink-3">
No account matches that. Try the instance UUID from the customer&rsquo;s
email.
</p>
</TBody>
</Table>
{!isFetching && rows.length === 0 && (
<EmptyState
title={q ? "No account matches that." : "No accounts yet."}
body={q ? "Try the instance UUID from the customer's email — it resolves to the account that owns it." : undefined}
/>
)}
</div>
</Panel>
</div>
);
}
@@ -5,6 +5,9 @@ import { useParams } from "next/navigation";
import Link from "next/link";
import { api } from "@/lib/api";
import { formatDate } from "@/lib/format";
import { PageHeader } from "@/components/PageHeader";
import { EmptyState, Panel } from "@/components/Panel";
import { Sub, TBody, TD, TH, THead, TR, Table } from "@/components/Table";
export default function AccountDetailPage() {
const id = String(useParams().id);
@@ -16,94 +19,138 @@ export default function AccountDetailPage() {
if (isLoading || !data) return <p className="text-ink-3">Loading</p>;
return (
<div className="grid gap-8">
<header className="grid gap-1">
<h1 className="text-3xl">{data.account.name}</h1>
<p className="font-mono text-[0.82rem] text-ink-3">
{data.account.billing_email} · {data.account.account_id}
</p>
</header>
<div className="grid gap-5">
<PageHeader
back={{ href: "/staff/accounts", label: "Accounts" }}
title={data.account.name}
subtitle={data.account.billing_email}
record={[
{ key: "Account", value: data.account.account_id, copy: true },
{ key: "Status", value: data.account.status },
...(data.account.paddle_customer_id ? [{ key: "Paddle", value: data.account.paddle_customer_id, copy: true }] : []),
]}
/>
<Panel title="Instances">
<ul className="grid gap-2">
{data.instances.map((i) => (
<li key={i.instance_id} className="flex flex-wrap justify-between gap-2">
<Link
href={`/staff/instances/${i.instance_id}`}
className="text-accent underline"
>
{i.name || i.instance_id}
</Link>
<span className="font-mono text-[0.82rem] text-ink-3">
{i.deployment} · {i.tier ?? "no tier"} · {i.status}
</span>
</li>
))}
{data.instances.length === 0 && <li className="text-ink-3">None.</li>}
</ul>
{/*
* Four lists of "thing · thing · thing" became four tables. Each row
* held three or four separate facts run into one string with
* middots, which cannot be scanned down a column — and a staff
* screen is read by scanning down a column.
*/}
<Panel title="Instances" meta={String(data.instances.length)} bodyless>
<Table>
<THead>
<TR className="hover:bg-transparent">
<TH>Instance</TH>
<TH>Deployment</TH>
<TH>Tier</TH>
<TH>Status</TH>
<TH />
</TR>
</THead>
<TBody>
{data.instances.map((i) => (
<TR key={i.instance_id}>
<TD>
<Link href={`/staff/instances/${i.instance_id}`} className="font-semibold text-accent no-underline hover:underline">
{i.name || "Unnamed instance"}
</Link>
<Sub>
<span className="font-mono">{i.instance_id.slice(0, 8)}</span>
</Sub>
</TD>
<TD className="text-ink-2">{i.deployment === "cloud" ? "Cloud" : "Self-hosted"}</TD>
<TD className="text-ink-2">{i.tier?.replace("_", " ") ?? "—"}</TD>
<TD className="text-ink-2">{i.status}</TD>
<TD numeric>
<Link href={`/staff/instances/${i.instance_id}`} className="font-mono text-[0.7rem] uppercase tracking-[0.1em] text-ink-3 no-underline hover:text-accent">
Open
</Link>
</TD>
</TR>
))}
</TBody>
</Table>
{data.instances.length === 0 && <EmptyState title="No instances on this account." body="They have signed up but not created or linked anything yet." />}
</Panel>
<Panel title="Subscriptions">
<ul className="grid gap-2">
{data.subscriptions.map((s) => (
<li key={s.subscription_id} className="flex flex-wrap justify-between gap-2">
<span>
{s.tier.replace("_", " ")} · {s.term}
</span>
<span className="font-mono text-[0.82rem] text-ink-3">
{s.status} · renews {formatDate(s.current_period_end)}
</span>
</li>
))}
{data.subscriptions.length === 0 && <li className="text-ink-3">None.</li>}
</ul>
<Panel title="Subscriptions" meta={String(data.subscriptions.length)} bodyless>
<Table>
<THead>
<TR className="hover:bg-transparent">
<TH>Tier</TH>
<TH>Billing</TH>
<TH>Status</TH>
<TH>Renews</TH>
</TR>
</THead>
<TBody>
{data.subscriptions.map((s) => (
<TR key={s.subscription_id}>
<TD>{s.tier.replace("_", " ")}</TD>
<TD className="text-ink-2">{s.term}</TD>
<TD className="text-ink-2">{s.status}</TD>
<TD className="whitespace-nowrap font-mono tabular-nums text-ink-2">{formatDate(s.current_period_end)}</TD>
</TR>
))}
</TBody>
</Table>
{data.subscriptions.length === 0 && <EmptyState title="No subscriptions." body="Everything on this account is Free, or nothing has been bought yet." />}
</Panel>
<Panel title="People">
<ul className="grid gap-2">
{data.users.map((u) => (
<li key={u.user_id} className="flex flex-wrap justify-between gap-2">
<span className="font-mono text-[0.82rem]">{u.email}</span>
<span className="font-mono text-[0.82rem] text-ink-3">
{u.verified_at
? `verified ${formatDate(u.verified_at)}`
: "not verified"}
</span>
</li>
))}
{data.users.length === 0 && (
<li className="text-ink-3">
None this is a cloud account, so its people sign in with their
control-plane details.
</li>
)}
</ul>
<Panel title="People" meta={String(data.users.length)} bodyless>
<Table>
<THead>
<TR className="hover:bg-transparent">
<TH>Email</TH>
<TH>Role</TH>
<TH>Verified</TH>
</TR>
</THead>
<TBody>
{data.users.map((u) => (
<TR key={u.user_id}>
<TD className="font-mono text-[0.82rem]">{u.email}</TD>
<TD className="text-ink-2">{u.account_role}</TD>
<TD className="text-ink-2">
{u.verified_at ? (
<span className="font-mono tabular-nums">{formatDate(u.verified_at)}</span>
) : (
<span className="font-mono text-[0.7rem] uppercase tracking-[0.1em] text-warn">Not verified</span>
)}
</TD>
</TR>
))}
</TBody>
</Table>
{data.users.length === 0 && (
<EmptyState title="No HQ people on this account." body="This is a cloud account, so its people sign in with their control-plane details instead." />
)}
</Panel>
<Panel title="Audit">
<ul className="grid gap-1 font-mono text-[0.82rem]">
{data.audit.map((e, n) => (
<li key={n} className="flex flex-wrap justify-between gap-2 text-ink-2">
<span>
{e.action} · {e.actor}
</span>
<span className="tabular-nums text-ink-3">
{formatDate(e.created_at)}
</span>
</li>
))}
{data.audit.length === 0 && <li className="text-ink-3">Nothing yet.</li>}
</ul>
<Panel title="Audit" meta="Newest first" bodyless>
<Table>
<THead>
<TR className="hover:bg-transparent">
<TH>Date</TH>
<TH>Actor</TH>
<TH>Action</TH>
<TH>Target</TH>
</TR>
</THead>
<TBody>
{data.audit.map((e, n) => (
<TR key={n}>
<TD className="whitespace-nowrap font-mono tabular-nums text-ink-2">{formatDate(e.created_at)}</TD>
<TD className="text-ink-2">{e.actor}</TD>
<TD className="font-mono text-[0.8rem]">{e.action}</TD>
<TD className="text-ink-2">{e.target ?? "—"}</TD>
</TR>
))}
</TBody>
</Table>
{data.audit.length === 0 && <EmptyState title="Nothing recorded against this account yet." />}
</Panel>
</div>
);
}
function Panel({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="grid gap-3 rounded border border-rule bg-panel p-5">
<h2 className="text-xl">{title}</h2>
{children}
</section>
);
}
@@ -1,9 +1,13 @@
import { AccountSearch } from "./AccountSearch";
import { PageHeader } from "@/components/PageHeader";
export default function AccountsPage() {
return (
<div className="grid gap-6">
<h1 className="text-3xl">Accounts</h1>
<PageHeader
title="Accounts"
subtitle="Search by name, email, Paddle ID or instance UUID."
/>
<AccountSearch />
</div>
);
+62 -30
View File
@@ -4,45 +4,77 @@ import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { api } from "@/lib/api";
import { formatDate, formatStamp } from "@/lib/format";
import { Field } from "@/components/Field";
import { PageHeader } from "@/components/PageHeader";
import { controlClass } from "@/components/Button";
import { EmptyState, Panel } from "@/components/Panel";
import { Sub, TBody, TD, TH, THead, TR, Table } from "@/components/Table";
export default function AuditPage() {
const [filter, setFilter] = useState("");
const { data } = useQuery({ queryKey: ["staff-audit"], queryFn: () => api.staff.audit() });
const rows = (data ?? []).filter((e) =>
filter
? `${e.action} ${e.actor} ${e.target ?? ""}`.toLowerCase().includes(filter.toLowerCase())
: true,
);
const rows = (data ?? []).filter((e) => (filter ? `${e.action} ${e.actor} ${e.target ?? ""}`.toLowerCase().includes(filter.toLowerCase()) : true));
return (
<div className="grid gap-6">
<h1 className="text-3xl">Audit</h1>
<Field
label="Filter"
value={filter}
onChange={(e) => setFilter(e.target.value)}
hint="Action, actor or target."
<PageHeader
title="Audit"
subtitle="Every mutating action across every account, newest first."
record={[{ key: "Showing", value: `${rows.length} of ${(data ?? []).length}` }]}
/>
<ul className="grid gap-2 rounded border border-rule bg-panel p-5 font-mono text-[0.82rem]">
{rows.map((e, n) => (
<li
key={n}
className="grid gap-1 border-b border-rule-soft pb-2 last:border-0 sm:grid-cols-[11rem_1fr]"
>
<span className="tabular-nums text-ink-3">
{formatDate(e.created_at)} {formatStamp(e.created_at)}
</span>
<span className="text-ink-2">
<b className="text-ink">{e.action}</b> · {e.actor}
{e.target && ` · ${e.target}`}
{e.detail && ` · ${e.detail}`}
</span>
</li>
))}
{rows.length === 0 && <li className="text-ink-3">Nothing matches that.</li>}
</ul>
<Panel>
<label className="grid gap-1.5">
<span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">Filter</span>
<input
type="search"
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="Action, actor or target"
className={controlClass()}
/>
</label>
</Panel>
{/*
* A table, not a list of mono sentences joined by middots. Every row
* held five separate facts run together into one string, so nothing
* could be scanned down a column — which is the only way anyone
* reads an audit log looking for "who did this".
*/}
<Panel bodyless>
<Table>
<THead>
<TR className="hover:bg-transparent">
<TH>Time</TH>
<TH>Actor</TH>
<TH>Action</TH>
<TH>Target</TH>
<TH>Detail</TH>
</TR>
</THead>
<TBody>
{rows.map((e, n) => (
<TR key={n}>
<TD className="whitespace-nowrap font-mono text-[0.78rem] tabular-nums text-ink-2">
{formatStamp(e.created_at)}
<Sub>{formatDate(e.created_at)}</Sub>
</TD>
<TD className="text-ink-2">{e.actor}</TD>
<TD className="font-mono text-[0.8rem]">{e.action}</TD>
<TD className="text-ink-2">{e.target ?? "—"}</TD>
<TD className="text-[0.82rem] text-ink-3">{e.detail ?? "—"}</TD>
</TR>
))}
</TBody>
</Table>
{rows.length === 0 && (
<EmptyState
title={filter ? "Nothing matches that." : "No actions recorded yet."}
body={filter ? "Clear the filter to see the whole log." : "Every licence issued, relinked or reaped is written here as it happens."}
/>
)}
</Panel>
</div>
);
}
@@ -0,0 +1,169 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { PageHeader } from "@/components/PageHeader";
import { PageFrame } from "@/components/PageFrame";
import { Panel } from "@/components/Panel";
import { TBody, TD, TH, THead, TR, Table } from "@/components/Table";
import { api, type CatalogueRow, type Term } from "@/lib/api";
const ENVS = ["sandbox", "production"] as const;
/* Self-hosted sells annual only, so the monthly cell is not rendered for it
* rather than rendered and rejected. The backend refuses one either way; this is
* so nobody types into a field that cannot be saved. */
function termsFor(deployment: string): Term[] {
return deployment === "self_hosted" ? ["annual"] : ["monthly", "annual"];
}
function componentLabel(r: CatalogueRow): string {
if (r.kind === "base") return "Base fee";
if (r.kind === "limit") return `Per ${r.limit_key?.replace("max_", "")}`;
return `Feature: ${r.feature_key}`;
}
function rowKey(r: CatalogueRow): string {
return [r.deployment, r.tier, r.kind, r.limit_key ?? "", r.feature_key ?? ""].join("/");
}
export default function CataloguePage() {
const qc = useQueryClient();
const { data: rows = [], isLoading } = useQuery({
queryKey: ["staff", "catalogue"],
queryFn: api.staff.catalogue,
});
const [drafts, setDrafts] = useState<Record<string, CatalogueRow["price_ids"]>>({});
const save = useMutation({
mutationFn: (r: CatalogueRow) => api.staff.updateCatalogue(r),
onSuccess: () => qc.invalidateQueries({ queryKey: ["staff", "catalogue"] }),
});
const groups = Array.from(new Set(rows.map((r) => `${r.deployment}/${r.tier}`)));
return (
<div className="grid gap-6">
<PageHeader
title="Catalogue"
back={{ href: "/staff", label: "Operations" }}
subtitle="Every priceable component. This is the only place a Paddle price ID lives."
/>
<PageFrame
aside={
<aside className="space-y-3 text-[0.82rem] text-ink-2">
<p>
A component with no price ID is free. A feature with no price is a
toggle a customer may take at no charge; giving it a price here is
all it takes to start charging for it.
</p>
<p>
Free is priced by nothing and has no rows. That absence is what
keeps it outside Paddle.
</p>
<p>
Changing a price affects the next checkout only. It cannot touch an
issued licence.
</p>
</aside>
}
>
{isLoading ? (
<p className="text-[0.85rem] text-ink-3">Loading</p>
) : (
<div className="grid gap-4">
{groups.map((g) => {
const [deployment, tier] = g.split("/");
const terms = termsFor(deployment);
return (
<Panel key={g} title={`${deployment === "cloud" ? "Cloud" : "Self-Hosted"} ${tier}`} meta={terms.join(" · ")} bodyless>
<Table className="min-w-[42rem]">
<THead>
<TR className="hover:bg-transparent">
<TH>Component</TH>
{ENVS.map((env) =>
terms.map((t) => (
<TH key={`${env}-${t}`}>
{env} / {t}
</TH>
)),
)}
<TH />
</TR>
</THead>
<TBody>
{rows
.filter(
(r) =>
r.deployment === deployment &&
r.tier === tier,
)
.map((r) => {
const k = rowKey(r);
const ids = drafts[k] ?? r.price_ids ?? {};
const dirty =
JSON.stringify(ids) !==
JSON.stringify(r.price_ids ?? {});
return (
<TR key={k}>
<TD className="text-ink">{componentLabel(r)}</TD>
{ENVS.map((env) =>
terms.map((t) => (
<TD key={`${env}-${t}`}>
<input
value={
ids[env]?.[t] ?? ""
}
placeholder="pri_…"
onChange={(e) =>
setDrafts({
...drafts,
[k]: {
...ids,
[env]: {
...(ids[
env
] ?? {}),
[t]: e
.target
.value,
},
},
})
}
className="w-40 rounded border border-rule bg-panel-2 px-2 py-1 font-mono text-[0.78rem] text-ink focus:border-accent focus:outline-none"
/>
</TD>
)),
)}
<TD numeric>
<button
type="button"
disabled={
!dirty || save.isPending
}
onClick={() =>
save.mutate({
...r,
price_ids: ids,
})
}
className="rounded border border-accent px-2.5 py-1 font-mono text-[0.7rem] uppercase tracking-[0.1em] text-accent disabled:opacity-40"
>
Save
</button>
</TD>
</TR>
);
})}
</TBody>
</Table>
</Panel>
);
})}
</div>
)}
</PageFrame>
</div>
);
}
@@ -6,15 +6,9 @@ import { ApiError, api, type Tier } from "@/lib/api";
import { Button } from "@/components/Button";
import { Field } from "@/components/Field";
export function IssuePanel({
instanceId,
deployment,
}: {
instanceId: string;
deployment: string;
}) {
export function IssuePanel({ instanceId }: { instanceId: string }) {
const qc = useQueryClient();
const [tier, setTier] = useState<Tier>(deployment === "cloud" ? "professional" : "self_hosted");
const [tier, setTier] = useState<Tier>("professional");
const [term, setTerm] = useState("annual");
const [newId, setNewId] = useState("");
const [error, setError] = useState<string | undefined>();
@@ -37,28 +31,16 @@ export function IssuePanel({
<section className="grid gap-4 border-t border-rule-soft pt-5">
<div className="flex flex-wrap items-end gap-3">
<label className="grid gap-1.5">
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
Tier
</span>
<select
value={tier}
onChange={(e) => setTier(e.target.value as Tier)}
className="rounded border border-rule bg-panel-2 px-2.5 py-2"
>
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">Tier</span>
<select value={tier} onChange={(e) => setTier(e.target.value as Tier)} className="rounded border border-rule bg-panel-2 px-2.5 py-2">
<option value="free">Free</option>
<option value="professional">Professional</option>
<option value="self_hosted">Self Hosted</option>
<option value="enterprise">Enterprise</option>
</select>
</label>
<label className="grid gap-1.5">
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
Term
</span>
<select
value={term}
onChange={(e) => setTerm(e.target.value)}
className="rounded border border-rule bg-panel-2 px-2.5 py-2"
>
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">Term</span>
<select value={term} onChange={(e) => setTerm(e.target.value)} className="rounded border border-rule bg-panel-2 px-2.5 py-2">
<option value="annual">Annual</option>
<option value="monthly">Monthly</option>
</select>
@@ -69,18 +51,8 @@ export function IssuePanel({
</div>
<div className="flex flex-wrap items-end gap-3">
<Field
label="Relink to instance ID"
value={newId}
onChange={(e) => setNewId(e.target.value)}
hint="Staff relinks are not capped — the customer cap exists to put you in the loop."
/>
<Button
type="button"
variant="line"
onClick={() => relink.mutate()}
disabled={!newId.trim()}
>
<Field label="Relink to instance ID" value={newId} onChange={(e) => setNewId(e.target.value)} hint="Staff relinks are not capped the customer cap exists to put you in the loop." />
<Button type="button" variant="line" onClick={() => relink.mutate()} disabled={!newId.trim()}>
Relink
</Button>
</div>
@@ -1,17 +1,24 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useParams } from "next/navigation";
import { useState } from "react";
import Link from "next/link";
import clsx from "clsx";
import { api, type InjectionState } from "@/lib/api";
import { api, type Deployment, type InjectionState } from "@/lib/api";
import { Ledger } from "@/components/Ledger";
import { PageHeader } from "@/components/PageHeader";
import { TermBar } from "@/components/TermBar";
import { Panel } from "@/components/Panel";
import { licenceState } from "@/lib/format";
import PlanConfigurator, { type PlanChoice } from "@/components/PlanConfigurator";
import { IssuePanel } from "./IssuePanel";
import { RenamePanel } from "@/components/RenamePanel";
const INJECTION: Record<InjectionState, { label: string; tone: string }> = {
current: { label: "Control plane holds the current licence", tone: "text-valid" },
stale: {
label: "Control plane holds an older blob the reconciler will repair it",
label: "Control plane holds an older blob the reconciler will repair it",
tone: "text-warn",
},
missing: { label: "No matching instance in the control plane", tone: "text-expired" },
@@ -20,6 +27,7 @@ const INJECTION: Record<InjectionState, { label: string; tone: string }> = {
export default function StaffInstancePage() {
const id = String(useParams().id);
const qc = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ["staff-instance", id],
queryFn: () => api.staff.instance(id),
@@ -29,41 +37,163 @@ export default function StaffInstancePage() {
if (isLoading || !data) return <p className="text-ink-3">Loading</p>;
const inj = data.injection.state ? INJECTION[data.injection.state] : undefined;
const current = data.licenses.find((l) => !l.superseded_by);
// A cloud placeholder has no control-plane row yet, so there is no host to
// move and nothing to rename — the panel's wording and its control are both
// read from this one answer rather than from the deployment alone, which is
// how they came to contradict each other.
const movesHost = data.instance.deployment === "cloud" && !data.instance.placeholder;
const cloudPlaceholder = data.instance.deployment === "cloud" && data.instance.placeholder;
return (
<div className="grid gap-8">
<header className="grid gap-2">
<h1 className="text-3xl">{data.instance.name || data.instance.instance_id}</h1>
<p className="font-mono text-[0.82rem] tabular-nums text-ink-3">
{data.instance.instance_id}
</p>
<p className="text-[0.82rem]">
<Link
href={`/staff/accounts/${data.account.account_id}`}
className="text-accent underline"
>
{data.account.name || data.account.account_id}
</Link>
<span className="text-ink-3">
{" "}
· {data.instance.deployment} · {data.instance.status}
{data.instance.relink_count > 0 &&
` · ${data.instance.relink_count} relinks this term`}
</span>
</p>
{data.injection.applicable && inj && (
<p className={clsx("font-mono text-[0.72rem]", inj.tone)}>{inj.label}</p>
)}
</header>
<section className="grid gap-3 rounded border border-rule bg-panel p-5">
<h2 className="text-xl">Licence history</h2>
<Ledger licenses={data.licenses} />
<IssuePanel
instanceId={data.instance.instance_id}
deployment={data.instance.deployment}
<div className="grid gap-3">
<PageHeader
back={{
href: `/staff/accounts/${data.account.account_id}`,
label: data.account.name || "Account",
}}
title={data.instance.name || data.instance.instance_id}
subtitle={
<>
<Link href={`/staff/accounts/${data.account.account_id}`} className="text-accent underline">
{data.account.name || data.account.account_id}
</Link>
<span className="text-ink-3">
{" "}
· {data.instance.deployment} · {data.instance.status}
{data.instance.relink_count > 0 && ` · ${data.instance.relink_count} relinks this term`}
</span>
</>
}
record={[{ key: "Instance", value: data.instance.instance_id, copy: true }, ...(data.instance.slug ? [{ key: "Slug", value: data.instance.slug }] : [])]}
/>
</section>
{data.injection.applicable && inj && <p className={clsx("font-mono text-[0.72rem]", inj.tone)}>{inj.label}</p>}
</div>
{/*
* The live licence is the one nothing has superseded, which is the
* record's own statement of the fact — not its position in the
* array, which is the server's ordering and not a guarantee.
*/}
{current && (
<Panel title="Current licence" meta={current.license_id}>
<TermBar issuedAt={current.issued_at} expiresAt={current.expires_at} state={licenceState(current.expires_at, true)} className="max-w-xl" />
</Panel>
)}
<Panel title="Licence history" meta="Append-only">
<Ledger licenses={data.licenses} />
<IssuePanel instanceId={data.instance.instance_id} />
</Panel>
{/*
* Staff rename has no cooldown and does not start the customer's:
* fixing a name on someone's behalf must not spend their next 24
* hours.
*/}
<Panel title="Name" meta={movesHost ? "Moves the address" : "Label only"}>
{cloudPlaceholder ? (
// The API refuses this with a 409, so offering the control
// would only be a form that cannot succeed.
<p className="text-[0.85rem] text-ink-3">
This instance is not provisioned yet. Its name is set when the checkout provisions it, and it can be renamed after that.
</p>
) : (
/*
* Keyed on the instance so a success note cannot follow staff
* from one instance page to the next — the element stays
* mounted across that navigation.
*/
<RenamePanel
key={data.instance.instance_id}
movesHost={movesHost}
currentName={data.instance.name}
currentSlug={data.instance.slug ?? ""}
onRename={async (name) => {
const res = await api.staff.renameInstance(data.instance.instance_id, name);
qc.invalidateQueries({ queryKey: ["staff-instance", id] });
return res;
}}
/>
)}
</Panel>
<EntitlementSection instanceId={data.instance.instance_id} deployment={data.instance.deployment} />
</div>
);
}
function EntitlementSection({ instanceId, deployment }: { instanceId: string; deployment: Deployment }) {
const qc = useQueryClient();
const { data: plans = [] } = useQuery({
queryKey: ["staff", "plans"],
queryFn: api.staff.plans,
});
const { data: catalogue = [] } = useQuery({
queryKey: ["staff", "catalogue"],
queryFn: api.staff.catalogue,
});
const { data } = useQuery({
queryKey: ["staff", "entitlement", instanceId],
queryFn: () => api.staff.entitlement(instanceId),
retry: false,
});
const ent = data?.entitlement;
const [draft, setDraft] = useState<PlanChoice | null>(null);
const choice: PlanChoice =
draft ??
(ent
? {
tier: ent.tier,
term: ent.term,
servers: ent.desired.servers,
features: ent.desired.features ?? [],
}
: { tier: "professional", term: deployment === "self_hosted" ? "annual" : "monthly", servers: 3, features: [] });
const save = useMutation({
mutationFn: (grant: boolean) => api.staff.setEntitlement(instanceId, { ...choice, grant }),
onSuccess: () => {
setDraft(null);
qc.invalidateQueries({ queryKey: ["staff", "entitlement", instanceId] });
},
});
return (
<section className="rounded-lg border border-rule bg-panel p-4">
<header className="mb-3">
<h2 className="text-[0.95rem] font-medium text-ink">Entitlement</h2>
<p className="text-[0.78rem] text-ink-3">
What this instance is allowed. A licence is signed from <em>granted</em>, never from <em>desired</em>.
</p>
</header>
{ent && data?.pending && (
<p className="mb-3 rounded border border-warn/50 bg-panel-2 px-2.5 py-2 text-[0.82rem] text-ink-2">
Pending change currently granted {ent.granted.servers} servers, configured for {ent.desired.servers}
{ent.scheduled_change_at ? `, effective ${new Date(ent.scheduled_change_at).toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric" })}` : ""}.
</p>
)}
<PlanConfigurator deployment={deployment} value={choice} plans={plans} catalogue={catalogue} onChange={setDraft} disabled={save.isPending} />
<div className="mt-4 flex flex-wrap gap-2">
<button type="button" disabled={save.isPending} onClick={() => save.mutate(false)} className="rounded border border-rule px-3 py-1.5 text-[0.85rem] text-ink-2 disabled:opacity-40">
Save as configured
</button>
<button
type="button"
disabled={save.isPending}
onClick={() => save.mutate(true)}
className="rounded border border-accent/50 px-3 py-1.5 text-[0.85rem] text-accent disabled:opacity-40"
>
Save and grant
</button>
</div>
<p className="mt-2 text-[0.72rem] text-ink-3">Granting takes effect on the next licence issued. It does not issue one.</p>
{save.error && <p className="mt-2 text-[0.82rem] text-expired">{String((save.error as Error).message)}</p>}
</section>
);
}
+19 -18
View File
@@ -1,29 +1,30 @@
"use client";
import Link from "next/link";
import { RequireKind } from "@/lib/session";
import { AppBar, type NavLink } from "@/components/AppBar";
const LINKS = [
["/staff", "Operations"],
["/staff/accounts", "Accounts"],
["/staff/licenses", "Licences"],
["/staff/plans", "Plans"],
["/staff/audit", "Audit"],
] as const;
const LINKS: NavLink[] = [
{ href: "/staff", label: "Operations" },
{ href: "/staff/accounts", label: "Accounts" },
{ href: "/staff/licenses", label: "Licences" },
{ href: "/staff/plans", label: "Plans" },
{ href: "/staff/catalogue", label: "Catalogue" },
{ href: "/staff/audit", label: "Audit" },
];
export default function StaffLayout({ children }: { children: React.ReactNode }) {
return (
<RequireKind kind="staff">
<nav className="border-b border-rule-soft bg-panel-2">
<div className="mx-auto flex max-w-rail flex-wrap gap-5 px-5 py-2.5 font-mono text-[0.72rem] uppercase tracking-[0.06em]">
{LINKS.map(([href, label]) => (
<Link key={href} href={href} className="text-ink-3 hover:text-accent">
{label}
</Link>
))}
</div>
</nav>
<main className="mx-auto max-w-rail px-5 py-8">{children}</main>
<AppBar
links={LINKS}
staff
context={
<span className="rounded-sm border border-accent px-1.5 py-0.5 font-mono text-[0.64rem] uppercase tracking-[0.12em] text-accent">
Staff
</span>
}
/>
<main className="mx-auto max-w-rail px-5 py-7">{children}</main>
</RequireKind>
);
}
+100 -74
View File
@@ -4,7 +4,14 @@ import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { useState } from "react";
import { api, type Tier } from "@/lib/api";
import { formatDate } from "@/lib/format";
import { formatDate, licenceState } from "@/lib/format";
import { PageHeader } from "@/components/PageHeader";
import { controlClass } from "@/components/Button";
import { EmptyState, Panel } from "@/components/Panel";
import { Sub, TBody, TD, TH, THead, TR, Table } from "@/components/Table";
import { TermSpark } from "@/components/TermBar";
const SELECT = controlClass("w-auto");
export default function LicensesPage() {
const [tier, setTier] = useState<"" | Tier>("");
@@ -13,84 +20,103 @@ export default function LicensesPage() {
// Filtered here rather than server-side: the endpoint caps at 500 rows and
// staff are narrowing a list they can already see.
const rows = (data ?? []).filter(
(l) => (!tier || l.tier === tier) && (!reason || l.reason === reason),
);
const rows = (data ?? []).filter((l) => (!tier || l.tier === tier) && (!reason || l.reason === reason));
const filtered = Boolean(tier || reason);
return (
<div className="grid gap-6">
<h1 className="text-3xl">Licences</h1>
<div className="flex flex-wrap gap-3">
<select
value={tier}
onChange={(e) => setTier(e.target.value as Tier | "")}
className="rounded border border-rule bg-panel-2 px-2.5 py-2"
aria-label="Filter by tier"
>
<option value="">All tiers</option>
<option value="free">Free</option>
<option value="professional">Professional</option>
<option value="self_hosted">Self Hosted</option>
</select>
<select
value={reason}
onChange={(e) => setReason(e.target.value)}
className="rounded border border-rule bg-panel-2 px-2.5 py-2"
aria-label="Filter by reason"
>
<option value="">All reasons</option>
<option value="new">New</option>
<option value="renewal">Renewal</option>
<option value="tier_change">Tier change</option>
<option value="relink">Relink</option>
<option value="manual">Manual</option>
</select>
</div>
<div className="overflow-x-auto rounded border border-rule bg-panel">
<table className="w-full border-collapse text-left">
<thead>
<tr className="border-b border-rule bg-panel-2 font-mono text-[0.72rem] uppercase tracking-[0.08em] text-ink-3">
<th className="px-4 py-2.5">Issued</th>
<th className="px-4 py-2.5">Instance</th>
<th className="px-4 py-2.5">Tier</th>
<th className="px-4 py-2.5">Reason</th>
<th className="px-4 py-2.5">Expires</th>
<th className="px-4 py-2.5">State</th>
</tr>
</thead>
<tbody>
{rows.map((l) => (
<tr
key={l.license_id}
className="border-b border-rule-soft last:border-0"
>
<td className="px-4 py-3 font-mono tabular-nums">
{formatDate(l.issued_at)}
</td>
<td className="px-4 py-3">
<Link
href={`/staff/instances/${l.instance_id}`}
className="font-mono text-[0.82rem] text-accent underline"
>
{l.instance_id.slice(0, 8)}
</Link>
</td>
<td className="px-4 py-3">{l.tier.replace("_", " ")}</td>
<td className="px-4 py-3">{l.reason.replace("_", " ")}</td>
<td className="px-4 py-3 font-mono tabular-nums">
{formatDate(l.expires_at)}
</td>
<td className="px-4 py-3 text-ink-3">
{l.superseded_by ? "superseded" : "current"}
</td>
</tr>
))}
</tbody>
</table>
<PageHeader
title="Licences"
subtitle="Append-only. A renewal writes a new row and supersedes the old one."
record={[{ key: "Showing", value: `${rows.length} of ${(data ?? []).length}` }]}
/>
<Panel>
<div className="flex flex-wrap gap-3">
<label className="grid gap-1.5">
<span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">Tier</span>
<select value={tier} onChange={(e) => setTier(e.target.value as Tier | "")} className={SELECT} aria-label="Filter by tier">
<option value="">All tiers</option>
<option value="free">Free</option>
<option value="professional">Professional</option>
<option value="enterprise">Enterprise</option>
<option value="self_hosted">Self-Hosted (legacy)</option>
</select>
</label>
<label className="grid gap-1.5">
<span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">Reason</span>
<select value={reason} onChange={(e) => setReason(e.target.value)} className={SELECT} aria-label="Filter by reason">
<option value="">All reasons</option>
<option value="new">New</option>
<option value="renewal">Renewal</option>
<option value="tier_change">Tier change</option>
<option value="relink">Relink</option>
<option value="manual">Manual</option>
</select>
</label>
</div>
</Panel>
<Panel bodyless>
<Table>
<THead>
<TR className="hover:bg-transparent">
<TH>Issued</TH>
<TH>Instance</TH>
<TH>Tier</TH>
<TH>Reason</TH>
<TH>Term</TH>
<TH>Expires</TH>
<TH>State</TH>
</TR>
</THead>
<TBody>
{rows.map((l) => {
const dead = Boolean(l.superseded_by);
return (
// A superseded row is overprinted rather than hidden:
// it is the only record of why an instance stopped
// working on a given date.
<TR key={l.license_id} className={dead ? "text-ink-3" : undefined}>
<TD className="whitespace-nowrap font-mono tabular-nums">{formatDate(l.issued_at)}</TD>
<TD>
<Link href={`/staff/instances/${l.instance_id}`} className="font-mono text-[0.82rem] text-accent no-underline hover:underline">
{l.instance_id.slice(0, 8)}
</Link>
<Sub>
<span className="font-mono">{l.license_id.slice(0, 8)}</span>
</Sub>
</TD>
<TD>{l.tier.replace("_", " ")}</TD>
<TD className="text-ink-2">{l.reason.replace("_", " ")}</TD>
{/* A superseded row's term is not a countdown to
anything — it ended when its successor was
issued, so drawing a bar would invite a
comparison that means nothing. */}
<TD>
{dead ? (
<span className="font-mono text-[0.72rem] text-ink-3"></span>
) : (
<TermSpark issuedAt={l.issued_at} expiresAt={l.expires_at} state={licenceState(l.expires_at, true)} />
)}
</TD>
<TD className="whitespace-nowrap font-mono tabular-nums">{formatDate(l.expires_at)}</TD>
<TD>
<span className="font-mono text-[0.7rem] uppercase tracking-[0.1em] text-ink-3">{dead ? "superseded" : "current"}</span>
</TD>
</TR>
);
})}
</TBody>
</Table>
{rows.length === 0 && (
<p className="px-4 py-6 text-ink-3">No licences match those filters.</p>
<EmptyState
title={filtered ? "No licences match those filters." : "No licences issued yet."}
body={filtered ? "Clear a filter to widen the search." : "Every issue, renewal and relink writes a row here."}
/>
)}
</div>
</Panel>
</div>
);
}
+90 -9
View File
@@ -1,10 +1,17 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { API_BASE, NotConnected, api } from "@/lib/api";
import { NotConnectedPanel } from "@/components/NotConnected";
import { Queue } from "@/components/Queue";
import { daysRemaining } from "@/lib/format";
import { PageFrame, RailCard, RailFacts } from "@/components/PageFrame";
import { PageHeader } from "@/components/PageHeader";
import { EmptyState, Panel } from "@/components/Panel";
import { TBody, TD, TH, THead, TR, Table } from "@/components/Table";
import { StatePill } from "@/components/StatePill";
import { LinkButton } from "@/components/Button";
import { daysRemaining, formatStamp } from "@/lib/format";
const HOURS_48 = 48 * 3600_000;
@@ -22,27 +29,42 @@ export default function StaffDashboard() {
queryKey: ["instances", "awaiting_link"],
queryFn: () => api.staff.instances({ status: "awaiting_link" }),
});
const audit = useQuery({ queryKey: ["staff-audit"], queryFn: () => api.staff.audit() });
const allInstances = useQuery({
queryKey: ["instances", "all"],
queryFn: () => api.staff.instances(),
});
if (injection.error instanceof NotConnected) return <NotConnectedPanel url={API_BASE} />;
const stale = (unlinked.data ?? []).filter(
(i) => Date.now() - new Date(i.created_at).getTime() > HOURS_48,
);
const stale = (unlinked.data ?? []).filter((i) => Date.now() - new Date(i.created_at).getTime() > HOURS_48);
const failed = injection.data?.count ?? 0;
const instances = allInstances.data ?? [];
return (
<div className="grid gap-6">
<h1 className="text-3xl">Operations</h1>
<PageHeader
title="Operations"
subtitle={failed > 0 ? "Injection failures come first those instances are paying for a licence they have not received." : "Nothing failing. Queues below are routine chasing."}
actions={
<LinkButton variant="line" href="/staff/accounts">
Find an account
</LinkButton>
}
record={[{ key: "Checked", value: formatStamp(new Date().toISOString()) }]}
status={failed > 0 ? <StatePill state="expired" /> : <StatePill state="valid" />}
/>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Queue
title="Failed injections"
tone="expired"
count={injection.data?.count ?? 0}
count={failed}
items={(injection.data?.failed ?? []).slice(0, 4).map((i) => ({
label: i.name || i.instance_id,
href: `/staff/instances/${i.instance_id}`,
meta: i.inject_failed_at
? new Date(i.inject_failed_at).toISOString().slice(11, 16)
: "",
meta: i.inject_failed_at ? new Date(i.inject_failed_at).toISOString().slice(11, 16) : "",
}))}
/>
<Queue
@@ -76,6 +98,65 @@ export default function StaffDashboard() {
}))}
/>
</div>
<PageFrame
aside={
<RailCard title="Fleet">
<RailFacts
rows={[
{ label: "Instances", value: instances.length },
{
label: "Cloud",
value: instances.filter((i) => i.deployment === "cloud").length,
},
{
label: "Self-hosted",
value: instances.filter((i) => i.deployment === "self_hosted").length,
},
{
label: "Awaiting link",
value: (unlinked.data ?? []).length,
},
]}
/>
<Link href="/staff/licenses" className="text-[0.82rem] font-semibold text-accent underline">
All licences
</Link>
</RailCard>
}
>
<Panel
title="Recent activity"
actions={
<Link href="/staff/audit" className="font-mono text-[0.7rem] uppercase tracking-[0.1em] text-accent no-underline hover:underline">
Full audit &rarr;
</Link>
}
bodyless
>
<Table>
<THead>
<TR className="hover:bg-transparent">
<TH>Time</TH>
<TH>Actor</TH>
<TH>Action</TH>
<TH>Target</TH>
</TR>
</THead>
<TBody>
{(audit.data ?? []).slice(0, 12).map((e, n) => (
<TR key={n}>
<TD className="whitespace-nowrap font-mono tabular-nums text-ink-2">{new Date(e.created_at).toISOString().slice(11, 16)}</TD>
<TD className="text-ink-2">{e.actor}</TD>
<TD className="font-mono text-[0.8rem]">{e.action}</TD>
<TD className="text-ink-2">{e.target ?? "—"}</TD>
</TR>
))}
</TBody>
</Table>
{audit.data?.length === 0 && <EmptyState title="Nothing yet today." body="Every licence issued, relinked or reaped appears here as it happens." />}
</Panel>
</PageFrame>
</div>
);
}
+115 -89
View File
@@ -2,10 +2,92 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { api, type Plan } from "@/lib/api";
import { Button } from "@/components/Button";
import { api, type Deployment, type Plan, type Tier } from "@/lib/api";
import { ConfirmPlanChange } from "@/components/ConfirmPlanChange";
import { limitLabel } from "@/lib/format";
import { PageHeader } from "@/components/PageHeader";
import { Panel } from "@/components/Panel";
const SUPPORT_LEVELS = [
{ value: "community", label: "Community" },
{ value: "email_24_5", label: "Email, 24/5" },
{ value: "email_call_24_7", label: "Email + call, 24/7" },
] as const;
const LIMIT_FIELDS = [
{ key: "max_servers", label: "Servers" },
{ key: "max_monitors", label: "Monitors" },
{ key: "max_secret_groups", label: "Secret groups" },
{ key: "max_channels", label: "Channels" },
{ key: "audit_retention_days", label: "Audit history (days)" },
] as const;
/*
* -1 is Unlimited everywhere in the licence payload, so the form takes it
* literally rather than inventing a checkbox. A staff screen that hides the
* sentinel is a staff screen where nobody can tell whether a plan says
* unlimited or nothing at all.
*/
function AllowanceForm({ plan, onSave, saving }: { plan: Plan; onSave: (next: Plan) => void; saving: boolean }) {
const [draft, setDraft] = useState<Plan>(plan);
const dirty = JSON.stringify(draft) !== JSON.stringify(plan);
return (
<div className="grid gap-3">
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{LIMIT_FIELDS.map((f) => (
<label key={f.key} className="block">
<span className="mb-1 block text-[0.78rem] text-ink-3">{f.label}</span>
<input
type="number"
value={draft.base_limits[f.key]}
onChange={(e) =>
setDraft({
...draft,
base_limits: {
...draft.base_limits,
[f.key]: Number(e.target.value),
},
})
}
className="w-full rounded border border-rule bg-panel-2 px-2 py-1.5 text-[0.85rem] text-ink focus:border-accent focus:outline-none"
/>
<span className="mt-0.5 block text-[0.72rem] text-ink-3">1 is unlimited</span>
</label>
))}
<label className="block">
<span className="mb-1 block text-[0.78rem] text-ink-3">Support level</span>
<select
value={draft.support_level}
onChange={(e) => setDraft({ ...draft, support_level: e.target.value })}
className="w-full rounded border border-rule bg-panel-2 px-2 py-1.5 text-[0.85rem] text-ink focus:border-accent focus:outline-none"
>
{SUPPORT_LEVELS.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</select>
</label>
</div>
<label className="flex items-center gap-2 text-[0.85rem] text-ink-2">
<input type="checkbox" checked={draft.active} onChange={(e) => setDraft({ ...draft, active: e.target.checked })} />
Offered to customers
</label>
<p className="text-[0.78rem] text-ink-3">Changes apply to licences issued from now on. Existing licences snapshotted their plan and are unaffected.</p>
<button
type="button"
disabled={!dirty || saving}
onClick={() => onSave(draft)}
className="justify-self-start rounded border border-accent bg-accent px-3.5 py-2 text-[0.86rem] font-semibold text-accent-ink disabled:opacity-40"
>
{saving ? "Saving…" : "Save allowances"}
</button>
</div>
);
}
export default function PlansPage() {
const qc = useQueryClient();
@@ -15,113 +97,57 @@ export default function PlansPage() {
queryFn: () => api.staff.licenses(),
});
const [draft, setDraft] = useState<Plan | null>(null);
const [saving, setSaving] = useState<string | null>(null);
const save = useMutation({
mutationFn: (p: Plan) =>
api.staff.updatePlan(p.tier, {
name: p.name,
limits: p.limits,
features: p.features,
paddle_product_id: p.paddle_product_id,
paddle_price_ids: p.paddle_price_ids,
active: p.active,
}),
mutationFn: (p: Plan) => api.staff.updatePlan(p.deployment, p.tier, p),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["plans"] });
setDraft(null);
setSaving(null);
},
onError: () => setSaving(null),
});
const original = plans.data?.find((p) => p.tier === draft?.tier);
const original = plans.data?.find((p) => p.deployment === draft?.deployment && p.tier === draft?.tier);
return (
<div className="grid gap-6">
<h1 className="text-3xl">Plans</h1>
<PageHeader
title="Plans"
subtitle="The authoritative tier table six plans, two deployments by three tiers, base allowances only. Every issued licence snapshots the plan it was cut from, so editing one never rewrites an existing licence."
/>
{draft && original && (
<ConfirmPlanChange
plan={original}
next={draft}
issuedCount={
(licenses.data ?? []).filter(
(l) => l.tier === draft.tier,
).length
}
onConfirm={() => save.mutate(draft)}
issuedCount={(licenses.data ?? []).filter((l) => l.tier === draft.tier && l.deployment === draft.deployment).length}
onConfirm={() => {
setSaving(`${draft.deployment}/${draft.tier}`);
save.mutate(draft);
}}
onCancel={() => setDraft(null)}
/>
)}
<div className="grid gap-4 lg:grid-cols-3">
{(plans.data ?? []).map((p) => (
<section
key={p.tier}
className="grid gap-3 rounded border border-rule bg-panel p-5"
>
<h2 className="text-xl">{p.name}</h2>
<dl className="grid gap-1 font-mono text-[0.82rem] tabular-nums text-ink-2">
<div className="flex justify-between gap-2">
<dt>servers</dt>
<dd>{limitLabel(p.limits.max_servers)}</dd>
</div>
<div className="flex justify-between gap-2">
<dt>secret groups</dt>
<dd>
{limitLabel(p.limits.max_secret_groups)}
</dd>
</div>
<div className="flex justify-between gap-2">
<dt>channels</dt>
<dd>{limitLabel(p.limits.max_channels)}</dd>
</div>
<div className="flex justify-between gap-2">
<dt>features</dt>
<dd>{p.features?.join(", ") || "none"}</dd>
</div>
</dl>
{/* Guard rail two: deployment is shown, never edited. */}
<p className="flex items-center gap-2 rounded border border-rule bg-panel-2 px-2.5 py-2 text-[0.82rem] text-ink-3">
<span aria-hidden="true">🔒</span>
<span>
Deployment is fixed at{" "}
<b className="font-mono">{p.deployment}</b>.
Moving a tier between cloud and self-hosted is a
code change, not a form field.
</span>
</p>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="line"
onClick={() =>
setDraft({
...p,
limits: { ...p.limits, max_servers: 7 },
})
}
{(["cloud", "self_hosted"] as const).map((deployment: Deployment) => (
<section key={deployment} className="grid gap-3">
<h2 className="font-mono text-[0.68rem] uppercase tracking-[0.14em] text-ink-3">{deployment === "cloud" ? "Cloud" : "Self-Hosted"}</h2>
{(plans.data ?? [])
.filter((p) => p.deployment === deployment)
.map((p) => (
<Panel
key={`${p.deployment}/${p.tier}`}
title={p.name}
meta={`${p.deployment}/${p.tier}`}
actions={!p.active ? <span className="font-mono text-[0.64rem] uppercase tracking-[0.12em] text-warn">Not offered</span> : undefined}
>
Cap servers at 7
</Button>
<Button
type="button"
variant="line"
onClick={() =>
setDraft({
...p,
features: p.features.filter(
(f) => f !== "oidc",
),
})
}
>
Remove OIDC
</Button>
</div>
</section>
))}
</div>
<AllowanceForm plan={p} saving={saving === `${p.deployment}/${p.tier}`} onSave={(next: Plan) => setDraft(next)} />
</Panel>
))}
</section>
))}
</div>
);
}
+48 -43
View File
@@ -1,12 +1,12 @@
"use client";
import { useMutation } from "@tanstack/react-query";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { Suspense, useState } from "react";
import { ApiError, api } from "@/lib/api";
import { Button } from "@/components/Button";
import { Field } from "@/components/Field";
import { AuthMessage, AuthShell } from "@/components/AuthShell";
function AcceptForm() {
const token = useSearchParams().get("token") ?? "";
@@ -17,60 +17,65 @@ function AcceptForm() {
const accept = useMutation({
mutationFn: () => api.acceptInvite(token, password),
onSuccess: () => setDone(true),
onError: (e) =>
setError(e instanceof ApiError ? e.message : "Something went wrong. Try again."),
onError: (e) => setError(e instanceof ApiError ? e.message : "Something went wrong. Try again."),
});
if (!token) return <p className="text-ink-2">That link is missing its token.</p>;
if (!token)
return (
<AuthMessage
title="That link is incomplete"
body="It is missing its token. Use the link in the invitation exactly as sent — some mail clients cut long links in half."
action={{ href: "/login", label: "Go to sign in" }}
/>
);
if (done)
return (
<div className="grid gap-3">
<h1 className="text-3xl">You&apos;re in</h1>
<p className="text-ink-2">Sign in with your email address and new password.</p>
<Link href="/login" className="font-semibold text-accent underline">
Sign in
</Link>
</div>
<AuthMessage
title="You're in"
body="Sign in with your email address and the password you just set."
action={{ href: "/login", label: "Sign in" }}
/>
);
return (
<form
className="grid max-w-md gap-4"
onSubmit={(e) => {
e.preventDefault();
setError(null);
accept.mutate();
}}
<AuthShell
title="Choose a password"
lede="You have been invited to a Vantage HQ account."
footnote="Nobody who invited you can see this password, and it is never sent to them."
>
<h1 className="text-3xl">Choose a password</h1>
<p className="text-ink-2">
This password signs you into Vantage HQ and into every instance you are given
access to. Nobody who invited you can see it.
</p>
<Field
label="New password"
type="password"
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={12}
hint="At least 12 characters."
error={error ?? undefined}
/>
<Button type="submit" disabled={accept.isPending || password.length < 12}>
{accept.isPending ? "Setting…" : "Set password"}
</Button>
</form>
<form
className="grid gap-4"
onSubmit={(e) => {
e.preventDefault();
setError(null);
accept.mutate();
}}
>
<p className="text-[0.86rem] text-ink-2">This password signs you into Vantage HQ and into every instance you are given access to.</p>
<Field
label="New password"
type="password"
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={12}
hint="At least 12 characters."
error={error ?? undefined}
/>
<Button type="submit" disabled={accept.isPending || password.length < 12} className="w-full justify-center">
{accept.isPending ? "Setting…" : "Set password and continue"}
</Button>
</form>
</AuthShell>
);
}
export default function AcceptInvitePage() {
return (
<main className="mx-auto max-w-rail px-5 py-16">
<Suspense fallback={<p className="text-ink-3">Loading</p>}>
<AcceptForm />
</Suspense>
</main>
<Suspense fallback={<AuthShell title="Choose a password" lede="One moment." />}>
<AcceptForm />
</Suspense>
);
}
+11 -13
View File
@@ -1,28 +1,26 @@
import type { Metadata } from "next";
import "./globals.css";
import { Providers } from "@/components/Providers";
import { EnvBadge } from "@/components/EnvBadge";
import { THEME_BOOT_SCRIPT } from "@/lib/theme";
export const metadata: Metadata = {
title: "Vantage Licensing",
title: "Vantage HQ",
description: "Licences, instances and billing for Vantage.",
};
/*
* The masthead deliberately does NOT live here. It belongs to the authenticated
* layouts, so /login, /verify and /accept-invite stop rendering a bar
* whose navigation and account menu they cannot use.
*/
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
{/* Runs before first paint, so a dark-preferring viewer never sees white. */}
<script dangerouslySetInnerHTML={{ __html: THEME_BOOT_SCRIPT }} />
</head>
<body>
<header className="border-b border-rule bg-panel">
<div className="mx-auto flex max-w-rail flex-wrap items-center justify-between gap-4 px-5 py-4">
<span className="flex items-baseline gap-2 text-[1.16rem] font-extrabold tracking-[-0.02em]">
Vantage
<span className="font-mono text-[0.72rem] font-normal uppercase tracking-[0.14em] text-ink-3">
Licensing
</span>
</span>
<EnvBadge />
</div>
</header>
<Providers>{children}</Providers>
</body>
</html>
+37 -32
View File
@@ -2,11 +2,13 @@
import { useRouter } from "next/navigation";
import { useState } from "react";
import Link from "next/link";
import { API_BASE, ApiError, NotConnected, api } from "@/lib/api";
import { NotConnectedPanel } from "@/components/NotConnected";
import { Button } from "@/components/Button";
import { Field } from "@/components/Field";
import { AuthShell } from "@/components/AuthShell";
const SITE_URL = (process.env.NEXT_PUBLIC_SITE_URL ?? "").replace(/\/$/, "");
export default function LoginPage() {
const router = useRouter();
@@ -37,23 +39,25 @@ export default function LoginPage() {
if (offline)
return (
<Main>
<AuthShell title="Sign in">
<NotConnectedPanel url={API_BASE} />
</Main>
</AuthShell>
);
return (
<Main>
<h1 className="text-3xl">Sign in</h1>
<form onSubmit={submit} className="mt-6 grid gap-4">
<Field
label="Email"
type="email"
autoComplete="username"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<AuthShell
title="Sign in"
lede="Licences, instances and billing for your account."
/*
* HQ and the Vantage console are separate sign-ins on separate
* hosts, and the two get confused — someone lands here with their
* console password and reads the generic failure as a broken
* account. Saying which door this is costs one line.
*/
footnote="This is the portal for your licence and billing. Your servers are managed inside your Vantage instance, which signs in separately."
>
<form onSubmit={submit} className="grid gap-4">
<Field label="Email" type="email" autoComplete="username" required value={email} onChange={(e) => setEmail(e.target.value)} />
<Field
label="Password"
type="password"
@@ -64,26 +68,27 @@ export default function LoginPage() {
error={error ?? undefined}
/>
<label className="flex items-center gap-2 text-[0.82rem] text-ink-2">
<input
type="checkbox"
checked={staff}
onChange={(e) => setStaff(e.target.checked)}
/>
<input type="checkbox" checked={staff} onChange={(e) => setStaff(e.target.checked)} className="accent-[var(--accent)]" />
I work at Vantage
</label>
<div className="flex flex-wrap items-center gap-3">
<Button type="submit" disabled={busy}>
{busy ? "Signing in…" : "Sign in"}
</Button>
<Link href="/signup" className="text-[0.82rem] text-accent underline">
Create an account for a self-hosted licence
</Link>
</div>
<Button type="submit" disabled={busy} className="w-full justify-center">
{busy ? "Signing in…" : "Sign in"}
</Button>
</form>
</Main>
{SITE_URL && (
<>
<div className="h-px bg-rule-soft" />
{/* Signup lives on the marketing site's /start, not here. */}
<p className="text-center text-[0.82rem] text-ink-3">
No account?{" "}
<a href={`${SITE_URL}/start`} className="text-accent underline">
Create one
</a>
</p>
</>
)}
</AuthShell>
);
}
function Main({ children }: { children: React.ReactNode }) {
return <main className="mx-auto max-w-rail px-5 py-12">{children}</main>;
}
-92
View File
@@ -1,92 +0,0 @@
"use client";
import { useState } from "react";
import { ApiError, NotConnected, api } from "@/lib/api";
import { Button } from "@/components/Button";
import { Field } from "@/components/Field";
export default function SignupPage() {
const [form, setForm] = useState({ name: "", email: "", password: "", website: "" });
const [state, setState] = useState<"idle" | "busy" | "sent">("idle");
const [error, setError] = useState<string | null>(null);
async function submit(e: React.FormEvent) {
e.preventDefault();
setState("busy");
setError(null);
try {
await api.signup(form);
setState("sent");
} catch (err) {
setState("idle");
setError(
err instanceof NotConnected
? "The licensing service is not reachable from this page."
: err instanceof ApiError
? err.message
: "Could not create the account. Try again.",
);
}
}
return (
<main className="mx-auto max-w-rail px-5 py-12">
{state === "sent" ? (
<div className="grid max-w-xl gap-3">
<h1 className="text-3xl">Check your email</h1>
<p className="text-ink-2">
We sent a link to {form.email}. Open it to finish setting up your account
it expires in 24 hours. Nothing is created until you do.
</p>
</div>
) : (
<>
<h1 className="text-3xl">Create an account</h1>
<p className="mt-2 max-w-xl text-ink-2">
For self-hosted licences. If you run on our cloud, sign in with the same
details you use for your Vantage instance.
</p>
<form onSubmit={submit} className="mt-6 grid gap-4">
<Field
label="Organisation"
required
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
/>
<Field
label="Email"
type="email"
required
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
/>
<Field
label="Password"
type="password"
required
minLength={12}
hint="At least 12 characters."
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
error={error ?? undefined}
/>
{/* Honeypot: off-screen, unlabelled for humans, irresistible to bots. */}
<input
type="text"
name="website"
tabIndex={-1}
autoComplete="off"
aria-hidden="true"
value={form.website}
onChange={(e) => setForm({ ...form, website: e.target.value })}
className="absolute left-[-9999px] h-0 w-0"
/>
<Button type="submit" disabled={state === "busy"}>
{state === "busy" ? "Creating…" : "Create account"}
</Button>
</form>
</>
)}
</main>
);
}
+38 -27
View File
@@ -2,9 +2,11 @@
import { useQuery } from "@tanstack/react-query";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { Suspense, useEffect } from "react";
import { api } from "@/lib/api";
import { AuthMessage, AuthShell } from "@/components/AuthShell";
const SITE_URL = (process.env.NEXT_PUBLIC_SITE_URL ?? "").replace(/\/$/, "");
function Verify() {
const router = useRouter();
@@ -25,50 +27,59 @@ function Verify() {
router.replace(`/accept-invite?token=${encodeURIComponent(token)}`);
}
}, [needsPassword, token, router]);
if (needsPassword) return <Message title="One moment…" body="Taking you to set a password." />;
if (needsPassword) return <AuthShell title="One moment…" lede="Taking you to set a password." />;
if (!token)
return (
<Message
<AuthMessage
title="That link is incomplete"
body="It is missing its token. Use the link in the email exactly as sent."
body="It is missing its token. Use the link in the email exactly as sent — some mail clients cut long links in half."
action={{ href: "/login", label: "Go to sign in" }}
/>
);
if (isLoading) return <Message title="Verifying…" body="One moment." />;
if (isLoading) return <AuthShell title="Verifying…" lede="One moment." />;
if (error || !data?.verified)
return (
<Message
<AuthMessage
title="That link is invalid or has expired"
body="Links last 24 hours and can only be used once. Sign up again to get a fresh one."
body="Links last 24 hours and can only be used once. Signing in will send you a fresh one."
action={{ href: "/login", label: "Go to sign in" }}
/>
);
return (
<div className="grid max-w-xl gap-3">
<h1 className="text-3xl">Email verified</h1>
<p className="text-ink-2">Your account is ready.</p>
<Link href="/login" className="justify-self-start text-accent underline">
<AuthShell
title="Email verified"
lede="Your account is ready."
footnote={
SITE_URL ? (
<>
New to Vantage? The{" "}
<a href={`${SITE_URL}/docs`} className="text-accent underline">
getting started guide
</a>{" "}
walks through your first instance.
</>
) : undefined
}
>
<p className="text-[0.9rem] text-ink-2">Sign in to create your first instance. The Free tier covers 5 servers and needs no card.</p>
<a
href="/login"
className="inline-flex items-center justify-center gap-2 rounded border border-accent bg-accent px-3.5 py-2 text-[0.86rem] font-semibold text-accent-ink no-underline"
>
Sign in
</Link>
</div>
);
}
function Message({ title, body }: { title: string; body: string }) {
return (
<div className="grid max-w-xl gap-3">
<h1 className="text-3xl">{title}</h1>
<p className="text-ink-2">{body}</p>
</div>
</a>
</AuthShell>
);
}
export default function VerifyPage() {
return (
<main className="mx-auto max-w-rail px-5 py-12">
<Suspense fallback={null}>
<Verify />
</Suspense>
</main>
<Suspense fallback={<AuthShell title="Verifying…" lede="One moment." />}>
<Verify />
</Suspense>
);
}
+149
View File
@@ -0,0 +1,149 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { api } from "@/lib/api";
import { useSession } from "@/lib/session";
import { useTheme, type ThemePref } from "@/lib/theme";
const APPEARANCE: { value: ThemePref; label: string }[] = [
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
{ value: "system", label: "System" },
];
/*
* Everything here is about YOU rather than about the account: your settings,
* your password, how you want the app to look, and leaving. None of it is a
* destination worth a slot in the primary nav, which is why Settings moved off
* the bar and into this menu.
*/
export function AccountMenu({ staff = false }: { staff?: boolean }) {
const { session } = useSession();
const [open, setOpen] = useState(false);
const [pref, setPref] = useTheme();
const wrap = useRef<HTMLDivElement>(null);
const router = useRouter();
const qc = useQueryClient();
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (wrap.current && !wrap.current.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [open]);
const signOut = useMutation({
mutationFn: api.logout,
// Clear the cache before leaving: a cached account response outliving
// the session would show the next person who signs in on this browser
// the previous account's name for a beat.
onSettled: () => {
qc.clear();
router.replace("/login");
},
});
const email = session?.email ?? "";
const initials =
email
.split("@")[0]
.split(/[.\-_]/)
.slice(0, 2)
.map((p) => p[0]?.toUpperCase() ?? "")
.join("") || "?";
return (
<div className="relative" ref={wrap}>
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-haspopup="menu"
className={`flex items-center gap-2 rounded-sm border px-2 py-1 text-[0.8rem] ${
open ? "border-accent text-ink" : "border-rule text-ink-2"
} bg-panel hover:border-ink-3`}
>
<span className="grid h-[18px] w-[18px] shrink-0 place-items-center rounded-full bg-accent font-mono text-[0.56rem] font-bold text-accent-ink">
{initials}
</span>
<span className="hidden max-w-[16ch] truncate sm:inline">{email}</span>
<span aria-hidden className="text-[0.6rem] text-ink-3">
</span>
</button>
{open && (
<div
role="menu"
// --shadow rather than a literal: globals.css defines it per
// theme, and a hardcoded rgba would be a colour value living
// in a component, which this app's tokens rule forbids.
className="absolute right-0 top-[calc(100%+8px)] z-50 grid w-64 overflow-hidden rounded border border-rule bg-panel shadow-[var(--shadow)]"
>
<div className="grid gap-0.5 border-b border-rule-soft px-3 py-2.5">
<strong className="truncate text-[0.86rem]">{email}</strong>
<span className="font-mono text-[0.66rem] uppercase tracking-[0.1em] text-ink-3">
{staff ? "Vantage staff" : (session?.account_role ?? "member")}
</span>
</div>
{!staff && (
<Link
href="/settings"
role="menuitem"
onClick={() => setOpen(false)}
className="px-3 py-2 text-[0.86rem] text-ink hover:bg-accent-wash"
>
Settings
</Link>
)}
<div className="grid gap-1.5 border-y border-rule-soft px-3 py-2.5">
<span className="font-mono text-[0.66rem] uppercase tracking-[0.12em] text-ink-3">
Appearance
</span>
<div className="flex overflow-hidden rounded-sm border border-rule">
{APPEARANCE.map((a) => (
<button
key={a.value}
type="button"
onClick={() => setPref(a.value)}
aria-pressed={pref === a.value}
className={`flex-1 px-0 py-1 font-mono text-[0.62rem] uppercase tracking-[0.08em] ${
pref === a.value
? "bg-accent text-accent-ink"
: "bg-panel text-ink-3 hover:text-ink-2"
}`}
>
{a.label}
</button>
))}
</div>
</div>
<button
type="button"
role="menuitem"
onClick={() => signOut.mutate()}
disabled={signOut.isPending}
className="px-3 py-2 text-left text-[0.86rem] text-expired hover:bg-accent-wash"
>
{signOut.isPending ? "Signing out…" : "Sign out"}
</button>
</div>
)}
</div>
);
}
+79
View File
@@ -0,0 +1,79 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { AccountMenu } from "@/components/AccountMenu";
import { EnvBadge } from "@/components/EnvBadge";
export type NavLink = { href: string; label: string };
/*
* One masthead in three zones: who you are acting as, where you can go, and
* which environment you are in.
*
* It replaces a brand bar and a separate nav strip. The nav's active state is
* derived from the pathname rather than hardcoded the previous customer nav
* marked Overview as current on every page, including the ones that weren't it.
*
* Staff sit on --panel-2 with a chip where the account name goes. web/ is locked
* to dark so this app defaults to light for the same reason CLAUDE.md gives:
* telling two consoles apart before you click Reissue. Staff and customer need
* that distinction from each other too, and one shade plus one chip buys it
* without a second palette.
*/
export function AppBar({ links, context, staff = false }: { links: NavLink[]; context?: React.ReactNode; staff?: boolean }) {
const pathname = usePathname();
const isCurrent = (href: string) =>
// The section root matches only exactly; deeper routes match by prefix,
// so /staff/accounts/:id still lights Accounts while /staff/accounts
// does not light Operations.
href === "/" || href === "/staff" ? pathname === href : pathname === href || pathname.startsWith(`${href}/`);
return (
<header className={`border-b border-rule ${staff ? "bg-panel-2" : "bg-panel"}`}>
<div className="mx-auto grid max-w-rail grid-cols-[auto_1fr_auto] items-center gap-4 px-5 md:gap-7">
<div className="col-start-1 row-start-1 flex min-w-0 items-center gap-3 py-2.5">
<span className="flex items-baseline gap-2 text-[1.16rem] font-extrabold tracking-[-0.02em]">
Vantage
<span className="font-mono text-[0.72rem] font-normal uppercase tracking-[0.14em] text-ink-3">HQ</span>
</span>
{context && (
<>
<span aria-hidden className="hidden h-[22px] w-px bg-rule sm:block" />
<span className="hidden min-w-0 sm:block">{context}</span>
</>
)}
</div>
<nav
aria-label={staff ? "Staff" : "Account"}
className="col-span-3 col-start-1 row-start-2 flex items-stretch gap-1 overflow-x-auto border-t border-rule-soft md:col-span-1 md:col-start-2 md:row-start-1 md:border-t-0"
>
{links.map((l) => {
const on = isCurrent(l.href);
return (
<Link
key={l.href}
href={l.href}
aria-current={on ? "page" : undefined}
className={`relative inline-flex shrink-0 items-center px-3 py-2.5 font-mono text-[0.72rem] uppercase tracking-[0.08em] md:py-0 ${
on ? "font-bold text-accent after:absolute after:inset-x-3 after:bottom-0 after:h-0.5 after:bg-accent after:content-['']" : "text-ink-3 hover:text-ink-2"
}`}
>
{l.label}
</Link>
);
})}
</nav>
<div className="col-start-3 row-start-1 flex items-center justify-end gap-2.5 py-2.5">
<span className="hidden sm:block">
<EnvBadge />
</span>
<AccountMenu staff={staff} />
</div>
</div>
</header>
);
}
+67
View File
@@ -0,0 +1,67 @@
import Link from "next/link";
/*
* The frame for every screen you can reach without a session: sign in, email
* verification, and accepting an invitation.
*
* These three had drifted into three different layouts. Sign in was a centred
* 26rem card with the lockup above it; verify and accept-invite were bare
* left-aligned text on the full 1200px rail, with no masthead, no panel and no
* brand anywhere on the page. Those two are the first screens a new customer
* ever sees — arriving from an email, on a domain they have not visited before
* — and they were the two that did not say whose product this is.
*
* There is no AppBar here on purpose: it carries navigation and an account
* menu, and none of it works without a session.
*/
export function AuthShell({
title,
lede,
children,
footnote,
}: {
title: string;
lede?: React.ReactNode;
children?: React.ReactNode;
/** Sits outside the panel: orientation, not part of the task. */
footnote?: React.ReactNode;
}) {
return (
<main className="mx-auto flex min-h-screen w-full max-w-[26rem] flex-col justify-center px-5 py-12">
{/* The masthead's lockup, unlinked: there is nowhere to go yet. */}
<div className="mb-7 flex flex-col items-center gap-2 text-center">
<span className="flex items-baseline gap-2 text-[1.5rem] font-extrabold tracking-[-0.02em]">
Vantage
<span className="font-mono text-[0.78rem] font-normal uppercase tracking-[0.14em] text-ink-3">HQ</span>
</span>
<h1 className="text-[1.16rem]">{title}</h1>
{lede && <p className="text-[0.86rem] text-ink-2">{lede}</p>}
</div>
{children && <div className="grid gap-4 rounded border border-rule bg-panel p-6 shadow-[var(--shadow)]">{children}</div>}
{footnote && <div className="mt-5 text-center text-[0.8rem] text-ink-3">{footnote}</div>}
</main>
);
}
/*
* A terminal state — verified, expired, already used, invalid. Always says what
* happened and what to do next: a dead end that only reports the failure leaves
* someone holding an email they cannot act on.
*/
export function AuthMessage({ title, body, action }: { title: string; body: React.ReactNode; action?: { href: string; label: string } }) {
return (
<AuthShell title={title}>
<p className="text-[0.9rem] text-ink-2">{body}</p>
{action && (
<Link
href={action.href}
className="inline-flex items-center justify-center gap-2 rounded border border-accent bg-accent px-3.5 py-2 text-[0.86rem] font-semibold text-accent-ink no-underline"
>
{action.label}
</Link>
)}
</AuthShell>
);
}
+58 -15
View File
@@ -1,25 +1,68 @@
import clsx from "clsx";
import Link from "next/link";
type Props = React.ButtonHTMLAttributes<HTMLButtonElement> & { variant?: "solid" | "line" };
type Variant = "solid" | "line";
/*
* Matches site/'s .btn--solid and .btn--line exactly, including the neutral
* border on the secondary variant. site/ does not have an accent-outlined
* button and this app should not invent one.
*/
export function Button({ variant = "solid", className, ...rest }: Props) {
return (
<button
{...rest}
className={clsx(
"inline-flex items-center gap-2 rounded border px-4 py-2.5 text-[0.94rem] font-semibold",
"transition-[filter,border-color] duration-150 hover:brightness-110",
variant === "solid"
? "border-accent bg-accent text-accent-ink"
: "border-rule bg-panel text-ink hover:border-ink-3",
rest.disabled && "cursor-not-allowed border-rule bg-panel text-ink-3 hover:brightness-100",
className,
)}
/>
/*
* The height every form control resolves to, buttons included.
*
* Padding alone cannot align them: a select is mono at 0.84rem and a button is
* sans at 0.94rem, so identical padding still leaves them ~7px apart and a
* filter row looks assembled from two different kits. It is the height the
* button's own padding already computed to, so buttons do not move — everything
* else comes up to meet them.
*/
export const CONTROL_HEIGHT = "h-11";
/*
* An input or select that sits on a form row with a button. Mono, because in
* this product the values typed into these are addresses, UUIDs and price IDs.
*/
export function controlClass(className?: string) {
return clsx(
CONTROL_HEIGHT,
"w-full rounded border border-rule bg-panel-2 px-2.5 font-mono text-[0.88rem] text-ink",
"focus:border-accent focus:outline-none",
className,
);
}
export function buttonClass(variant: Variant = "solid", disabled = false, className?: string) {
return clsx(
"inline-flex items-center gap-2 rounded border px-4 text-[0.94rem] font-semibold",
CONTROL_HEIGHT,
"transition-[filter,border-color] duration-150 hover:brightness-110",
variant === "solid" ? "border-accent bg-accent text-accent-ink" : "border-rule bg-panel text-ink hover:border-ink-3",
disabled && "cursor-not-allowed border-rule bg-panel text-ink-3 hover:brightness-100",
className,
);
}
type Props = React.ButtonHTMLAttributes<HTMLButtonElement> & { variant?: Variant };
export function Button({ variant = "solid", className, ...rest }: Props) {
return <button {...rest} className={buttonClass(variant, rest.disabled, className)} />;
}
/*
* A link that looks like a button. It exists so a navigation action never has to
* be an <a> wrapped around a <button> invalid markup, and it gives screen
* readers two nested controls where the page means one.
*/
export function LinkButton({ href, variant = "solid", external, className, children }: { href: string; variant?: Variant; external?: boolean; className?: string; children: React.ReactNode }) {
const cls = buttonClass(variant, false, className);
return external ? (
<a href={href} className={cls}>
{children}
</a>
) : (
<Link href={href} className={cls}>
{children}
</Link>
);
}
+40
View File
@@ -0,0 +1,40 @@
"use client";
import { useState } from "react";
import { initPaddle } from "@/lib/paddle";
/* Opens the Paddle overlay with the resolved line items and custom_data. The
* items come from the configurator via catalogue pricing; custom_data is what
* lets the webhook route without a lookup table. */
export function CheckoutButton({
items,
customData,
disabled,
label = "Continue to payment",
}: {
items: { priceId: string; quantity: number }[];
customData: { account_id: string; instance_id: string };
disabled?: boolean;
label?: string;
}) {
const [busy, setBusy] = useState(false);
async function open() {
setBusy(true);
const paddle = await initPaddle();
setBusy(false);
paddle?.Checkout.open({
items: items.map((i) => ({ priceId: i.priceId, quantity: i.quantity })),
customData,
});
}
return (
<button
type="button"
disabled={disabled || busy || items.length === 0}
onClick={open}
className="rounded border border-accent/50 px-3 py-1.5 text-[0.85rem] text-accent disabled:opacity-40"
>
{busy ? "Opening…" : label}
</button>
);
}
+19 -37
View File
@@ -5,46 +5,31 @@ import { Button } from "./Button";
/*
* Editing a plan changes what every future customer gets, so the confirmation
* names each field rather than asking "are you sure". Existing licences
* snapshotted their plan at issue time and are genuinely unaffected saying so
* snapshotted their plan at issue time and are genuinely unaffected saying so
* is what stops a well-meaning edit being followed by a panicked reissue.
*/
export function ConfirmPlanChange({
plan,
next,
issuedCount,
onConfirm,
onCancel,
}: {
plan: Plan;
next: Plan;
issuedCount: number;
onConfirm: () => void;
onCancel: () => void;
}) {
export function ConfirmPlanChange({ plan, next, issuedCount, onConfirm, onCancel }: { plan: Plan; next: Plan; issuedCount: number; onConfirm: () => void; onCancel: () => void }) {
const rows: { field: string; was: string; now: string }[] = [];
if (plan.limits.max_servers !== next.limits.max_servers)
const fields = ["max_servers", "max_monitors", "max_secret_groups", "max_channels", "audit_retention_days"] as const;
for (const f of fields) {
if (plan.base_limits[f] !== next.base_limits[f])
rows.push({
field: f,
was: limitLabel(plan.base_limits[f]),
now: limitLabel(next.base_limits[f]),
});
}
if (plan.support_level !== next.support_level)
rows.push({
field: "max_servers",
was: limitLabel(plan.limits.max_servers),
now: limitLabel(next.limits.max_servers),
field: "support_level",
was: plan.support_level || "none",
now: next.support_level || "none",
});
if (plan.limits.max_secret_groups !== next.limits.max_secret_groups)
rows.push({
field: "max_secret_groups",
was: limitLabel(plan.limits.max_secret_groups),
now: limitLabel(next.limits.max_secret_groups),
});
if (plan.limits.max_channels !== next.limits.max_channels)
rows.push({
field: "max_channels",
was: limitLabel(plan.limits.max_channels),
now: limitLabel(next.limits.max_channels),
});
if (plan.features.join(",") !== next.features.join(","))
if (plan.base_features.join(",") !== next.base_features.join(","))
rows.push({
field: "features",
was: plan.features.join(", ") || "none",
now: next.features.join(", ") || "none",
was: plan.base_features.join(", ") || "none",
now: next.base_features.join(", ") || "none",
});
return (
@@ -60,10 +45,7 @@ export function ConfirmPlanChange({
))}
{rows.length === 0 && <li className="text-ink-3">Nothing would change.</li>}
</ul>
<p className="text-[0.82rem] text-ink-3">
This applies to licences issued from now on. The {issuedCount} licences already
issued keep what they were signed with until each is reissued.
</p>
<p className="text-[0.82rem] text-ink-3">This applies to licences issued from now on. The {issuedCount} licences already issued keep what they were signed with until each is reissued.</p>
<div className="flex flex-wrap gap-3">
<Button type="button" onClick={onConfirm}>
Change plan
+12 -12
View File
@@ -1,7 +1,10 @@
import { controlClass } from "./Button";
export function Field({
label,
hint,
error,
className,
...input
}: React.InputHTMLAttributes<HTMLInputElement> & {
label: string;
@@ -10,18 +13,15 @@ export function Field({
}) {
return (
<label className="grid max-w-md gap-1.5">
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
{label}
</span>
<input
{...input}
className="rounded border border-rule bg-panel-2 px-2.5 py-2 font-mono text-ink"
/>
{error ? (
<span className="text-[0.82rem] text-expired">{error}</span>
) : hint ? (
<span className="text-[0.82rem] text-ink-3">{hint}</span>
) : null}
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">{label}</span>
{/*
* className is pulled out of the spread rather than left in it: it
* used to be spread onto the input and then overwritten by the
* hardcoded one below, so a caller passing className got nothing and
* no warning.
*/}
<input {...input} className={controlClass(className)} aria-invalid={error ? true : undefined} />
{error ? <span className="text-[0.82rem] text-expired">{error}</span> : hint ? <span className="text-[0.82rem] text-ink-3">{hint}</span> : null}
</label>
);
}
-131
View File
@@ -1,131 +0,0 @@
"use client";
import Link from "next/link";
import clsx from "clsx";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api, type Instance, type License } from "@/lib/api";
import { daysRemaining, formatDate, licenceState } from "@/lib/format";
import { StatePill } from "./StatePill";
const STRIPE = {
valid: "before:bg-valid",
warn: "before:bg-warn",
expired: "before:bg-expired",
none: "before:bg-accent",
} as const;
export function InstanceCard({
instance,
license,
reapAfterDays,
}: {
instance: Instance;
license?: License;
reapAfterDays?: number;
}) {
const state = licenceState(license?.expires_at, Boolean(license));
const days = license ? daysRemaining(license.expires_at) : 0;
const cloud = instance.deployment === "cloud";
const termDays = instance.tier === "free" ? 30 : 365;
const deleteInDays =
license && reapAfterDays ? daysRemaining(license.expires_at) + reapAfterDays : null;
const qc = useQueryClient();
const renew = useMutation({
mutationFn: () => api.renewInstance(instance.instance_id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["account"] }),
});
const canRenew = instance.tier === "free" && license !== undefined && days <= 7;
return (
<article
className={clsx(
"relative grid gap-3 rounded border border-rule bg-panel p-4 pl-5",
"before:absolute before:inset-y-0 before:left-0 before:w-1 before:content-['']",
STRIPE[state],
)}
>
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="text-lg">{instance.name || "Unnamed instance"}</h3>
<p className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
{cloud ? "Cloud" : "Self-hosted"}
{instance.tier ? ` · ${instance.tier.replace("_", " ")}` : ""}
</p>
</div>
<StatePill state={state} />
</div>
{state === "expired" && (
<p className="text-[0.82rem] text-ink-2">
Servers and monitors are still running, and your agents keep their keys. Changes
are disabled until you renew.
</p>
)}
{state === "expired" && deleteInDays !== null && (
<p className="text-[0.82rem] font-semibold text-expired">
{deleteInDays <= 0
? "Scheduled for deletion."
: `Deleted in ${deleteInDays} ${deleteInDays === 1 ? "day" : "days"} unless renewed.`}
</p>
)}
{state === "none" && (
<p className="text-[0.82rem] text-ink-2">
You have paid for this but it is not attached to an install yet, so no licence
has been issued. Linking takes a minute.
</p>
)}
{license && state !== "expired" && (
<div className="grid gap-1 font-mono text-[0.82rem] tabular-nums text-ink-2">
<span>{days} days remaining</span>
<div className="h-[3px] overflow-hidden rounded-sm bg-rule-soft">
<div
className={clsx("h-full", state === "warn" ? "bg-warn" : "bg-valid")}
style={{ width: `${Math.max(2, Math.min(100, (days / termDays) * 100))}%` }}
/>
</div>
<span>Renews {formatDate(license.expires_at)}</span>
</div>
)}
<div className="flex flex-wrap items-center gap-3">
{state === "none" ? (
<Link
href="/instances/link"
className="justify-self-start text-[0.82rem] font-semibold text-accent underline"
>
Link an install
</Link>
) : cloud && instance.slug ? (
<a
href={`https://${instance.slug}.vantage.hostxtra.co.uk`}
className="justify-self-start text-[0.82rem] font-semibold text-accent underline"
>
Open {instance.slug}.vantage.hostxtra.co.uk
</a>
) : (
<Link
href={`/instances/${instance.instance_id}`}
className="justify-self-start text-[0.82rem] font-semibold text-accent underline"
>
{state === "expired" ? "Renew and download" : "Licence and download"}
</Link>
)}
{canRenew && (
<button
type="button"
onClick={() => renew.mutate()}
disabled={renew.isPending}
className="justify-self-start rounded bg-accent px-3 py-1.5 text-[0.82rem] font-semibold text-accent-ink"
>
{renew.isPending ? "Renewing…" : "Renew"}
</button>
)}
</div>
</article>
);
}
+194
View File
@@ -0,0 +1,194 @@
"use client";
import Link from "next/link";
import clsx from "clsx";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { api, type Instance, type License } from "@/lib/api";
import { daysRemaining, formatDate, licenceState, limitLabel } from "@/lib/format";
import { StatePill } from "./StatePill";
import { TermBar } from "./TermBar";
import { Button, LinkButton } from "./Button";
const STRIPE = {
valid: "before:bg-valid",
warn: "before:bg-warn",
expired: "before:bg-expired",
none: "before:bg-accent",
} as const;
const KEY = (id: string) => `vantage-hq-record-open:${id}`;
/*
* One instance, open or closed.
*
* Closed it is a row name, tier, host, term bar, state. Open it adds what the
* licence includes, who can sign in, and the actions. Deliberately ONE component
* rather than a card and a detail panel: two components meant a single-instance
* account got a third of a row of summary with its substance a click away, and
* a six-instance account got a grid of summaries with no way to look closer.
*
* It defaults open when it is the only instance or when it needs attention,
* because the thing that needs you is the thing that should be open. A manual
* toggle is remembered per instance and beats the default from then on.
*/
export function InstanceRecord({ instance, license, reapAfterDays, defaultOpen = false }: { instance: Instance; license?: License; reapAfterDays?: number; defaultOpen?: boolean }) {
const state = licenceState(license?.expires_at, Boolean(license));
const days = license ? daysRemaining(license.expires_at) : 0;
const cloud = instance.deployment === "cloud";
const deleteInDays = license && reapAfterDays ? daysRemaining(license.expires_at) + reapAfterDays : null;
const [open, setOpen] = useState(defaultOpen);
useEffect(() => {
const saved = localStorage.getItem(KEY(instance.instance_id));
if (saved !== null) setOpen(saved === "1");
}, [instance.instance_id]);
const toggle = () => {
setOpen((v) => {
localStorage.setItem(KEY(instance.instance_id), v ? "0" : "1");
return !v;
});
};
const qc = useQueryClient();
const renew = useMutation({
mutationFn: () => api.renewInstance(instance.instance_id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["account"] }),
});
const canRenew = instance.tier === "free" && license !== undefined && days <= 7;
// Only fetched once the record is open, and only for cloud: a self-hosted
// install manages its own users and the endpoint refuses it.
const members = useQuery({
queryKey: ["members", instance.instance_id],
queryFn: () => api.members(instance.instance_id),
enabled: open && cloud,
});
const panelId = `record-${instance.instance_id}`;
return (
<article className={clsx("relative grid gap-3.5 rounded border border-rule bg-panel p-4 pl-5", "before:absolute before:inset-y-0 before:left-0 before:w-1 before:content-['']", STRIPE[state])}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="text-[1.22rem]">{instance.name || "Unnamed instance"}</h2>
<p className="mt-1 font-mono text-[0.68rem] uppercase tracking-[0.1em] text-ink-3">
{cloud ? "Cloud" : "Self-hosted"}
{instance.tier ? ` · ${instance.tier.replace("_", " ")}` : ""}
{` · created ${formatDate(instance.created_at)}`}
</p>
{cloud && instance.slug && (
<a href={`https://${instance.slug}.vantage.hostxtra.co.uk`} className="mt-1.5 inline-block font-mono text-[0.78rem] text-accent underline">
{instance.slug}.vantage.hostxtra.co.uk &rarr;
</a>
)}
</div>
<div className="flex shrink-0 items-center gap-2.5">
<StatePill state={state} />
<button
type="button"
onClick={toggle}
aria-expanded={open}
aria-controls={panelId}
aria-label={open ? "Hide details" : "Show details"}
className="grid h-[26px] w-[26px] place-items-center rounded-sm border border-rule bg-panel text-[0.6rem] text-ink-3 hover:border-accent hover:text-accent"
>
<span aria-hidden className={clsx("block transition-transform", open && "rotate-180")}>
</span>
</button>
</div>
</div>
{/* The term is drawn for an expired licence too. The old bar hid
itself once it lapsed, which removed the measurement at exactly
the moment it started mattering. */}
{license && <TermBar issuedAt={license.issued_at} expiresAt={license.expires_at} state={state} className="max-w-md" />}
{state === "expired" && (
<div className="grid gap-1">
<p className="text-[0.82rem] text-ink-2">Servers and monitors are still running, and your agents keep their keys. Changes are disabled until you renew.</p>
{deleteInDays !== null && (
<p className="text-[0.82rem] font-semibold text-expired">
{deleteInDays <= 0 ? "Scheduled for deletion." : `Deleted in ${deleteInDays} ${deleteInDays === 1 ? "day" : "days"} unless renewed.`}
</p>
)}
</div>
)}
{state === "none" && <p className="text-[0.82rem] text-ink-2">You have paid for this but it is not attached to an install yet, so no licence has been issued. Linking takes a minute.</p>}
<div id={panelId} className={clsx("gap-3.5", open ? "grid" : "hidden")}>
{license && (
<div className="grid gap-2 border-t border-rule-soft pt-3">
<p className="font-mono text-[0.68rem] uppercase tracking-[0.12em] text-ink-3">Included in {instance.tier?.replace("_", " ") ?? "this licence"}</p>
<div className="flex flex-wrap gap-x-7 gap-y-2.5">
<Stat n={limitLabel(license.limits.max_servers)} label="Servers" />
<Stat n={limitLabel(license.limits.max_secret_groups)} label="Secret groups" />
<Stat n={limitLabel(license.limits.max_channels)} label="Channels" />
<Stat n={license.features.length ? license.features.join(" · ") : "None"} label="Features" quiet={license.features.length === 0} />
</div>
</div>
)}
{cloud && (
<div className="grid gap-2 border-t border-rule-soft pt-3">
<p className="font-mono text-[0.68rem] uppercase tracking-[0.12em] text-ink-3">Who can sign in</p>
<div className="flex flex-wrap items-center gap-2">
{(members.data ?? []).map((m) => (
<span key={m.member_id} className="inline-flex items-center gap-1.5 rounded-full border border-rule-soft py-0.5 pl-0.5 pr-2.5 text-[0.78rem] text-ink-2">
<span className="grid h-[18px] w-[18px] place-items-center rounded-full bg-accent font-mono text-[0.56rem] font-bold text-accent-ink">
{m.email.slice(0, 2).toUpperCase()}
</span>
{m.email}
</span>
))}
{members.isLoading && <span className="text-[0.82rem] text-ink-3">Loading</span>}
{members.data?.length === 0 && <span className="text-[0.82rem] text-ink-3">Nobody yet.</span>}
<Link href={`/instances/${instance.instance_id}`} className="text-[0.82rem] font-semibold text-accent underline">
Manage access
</Link>
</div>
</div>
)}
<div className="flex flex-wrap items-center gap-2.5">
{state === "none" ? (
// Every unlicensed instance is answered from the purchase
// page — self-hosted Free and paid both start there, and
// both name the install's own UUID.
<LinkButton href="/purchase">Get a licence</LinkButton>
) : cloud && instance.slug ? (
<>
<LinkButton external href={`https://${instance.slug}.vantage.hostxtra.co.uk`}>
Open Cloud Instance
</LinkButton>
<LinkButton variant="line" href={`/instances/${instance.instance_id}`}>
View Instance Settings
</LinkButton>
</>
) : (
<LinkButton href={`/instances/${instance.instance_id}`}>View Instance Settings</LinkButton>
)}
{canRenew && (
<Button type="button" variant="line" onClick={() => renew.mutate()} disabled={renew.isPending}>
{renew.isPending ? "Renewing…" : "Renew"}
</Button>
)}
</div>
</div>
</article>
);
}
function Stat({ n, label, quiet }: { n: string; label: string; quiet?: boolean }) {
return (
<div className="grid gap-px">
<b className={clsx("tabular-nums tracking-[-0.02em]", quiet ? "text-[0.95rem] font-semibold text-ink-3" : "text-[1.18rem] font-extrabold")}>{n}</b>
<span className="font-mono text-[0.64rem] uppercase tracking-[0.1em] text-ink-3">{label}</span>
</div>
);
}
+7 -30
View File
@@ -13,16 +13,12 @@ const REASON: Record<License["reason"], string> = {
/*
* Licences are append-only: a renewal supersedes its predecessor rather than
* replacing it. So this is a ledger, not a table. Superseded rows stay visible
* and are overprinted the way a cancelled instrument is hiding them would
* and are overprinted the way a cancelled instrument is hiding them would
* destroy the only record of why an instance stopped working on a given date.
*/
export function Ledger({ licenses }: { licenses: License[] }) {
if (licenses.length === 0) {
return (
<p className="text-ink-2">
No licence has ever been issued for this instance, so it is read-only.
</p>
);
return <p className="text-ink-2">No licence has ever been issued for this instance, so it is read-only.</p>;
}
return (
@@ -30,22 +26,9 @@ export function Ledger({ licenses }: { licenses: License[] }) {
{licenses.map((l) => {
const dead = Boolean(l.superseded_by);
return (
<li
key={l.license_id}
className={clsx(
"grid gap-4 border-b border-rule-soft py-4 last:border-0 sm:grid-cols-[9.5rem_1fr]",
dead && "text-ink-3",
)}
>
<li key={l.license_id} className={clsx("grid gap-4 border-b border-rule-soft py-4 last:border-0 sm:grid-cols-[9.5rem_1fr]", dead && "text-ink-3")}>
<div className="font-mono text-[0.72rem] tabular-nums text-ink-3">
<b
className={clsx(
"block text-[0.82rem] font-semibold",
dead ? "text-ink-3" : "text-ink",
)}
>
{formatDate(l.issued_at)}
</b>
<b className={clsx("block text-[0.82rem] font-semibold", dead ? "text-ink-3" : "text-ink")}>{formatDate(l.issued_at)}</b>
{formatStamp(l.issued_at)}
</div>
<div className="grid justify-items-start gap-1.5">
@@ -56,20 +39,14 @@ export function Ledger({ licenses }: { licenses: License[] }) {
)}
<p className="flex flex-wrap items-center gap-2 font-semibold">
{l.tier.replace("_", " ")}
<span className="rounded-sm border border-rule px-1.5 py-0.5 font-mono text-[0.72rem] font-normal uppercase tracking-[0.09em] text-accent">
{REASON[l.reason]}
</span>
<span className="rounded-sm border border-rule px-1.5 py-0.5 font-mono text-[0.72rem] font-normal uppercase tracking-[0.09em] text-accent">{REASON[l.reason]}</span>
</p>
<p className="font-mono text-[0.72rem] tabular-nums text-ink-3">
{l.license_id.slice(0, 8)} · expires {formatDate(l.expires_at)} ·{" "}
{limitLabel(l.limits.max_servers)} servers · issued by {l.issued_by}
{l.license_id.slice(0, 8)} · expires {formatDate(l.expires_at)} · {limitLabel(l.limits.max_servers)} servers · issued by {l.issued_by}
{l.superseded_by && (
<>
{" "}
· replaced by{" "}
<span className="text-accent underline">
{l.superseded_by.slice(0, 8)}
</span>
· replaced by <span className="text-accent underline">{l.superseded_by.slice(0, 8)}</span>
</>
)}
</p>
+55 -42
View File
@@ -1,73 +1,86 @@
"use client";
import { useState } from "react";
import { Button } from "./Button";
import { Panel } from "./Panel";
/*
* A licence blob is signed public data, not a secret — it is useless on any
* instance other than the one it names. So it is safe to show inline, and
* showing it is what stops a blocked download from blocking a paying customer.
* That is also why it is never collapsed behind a toggle: someone whose
* clipboard and download are both blocked has to be able to select it by hand.
*
* It is evidence rather than content, so it is set in a well with a keyed strip
* saying what it is and how much of it there is, and given a fixed height. It
* used to run to 250px of base64 and was the largest thing on the page, which
* is a strange amount of room to give a string nobody reads.
*
* The download lives in the page header beside Renew, not here — it was in both
* places, which is one button too many for one file.
*/
export function LicenceDelivery({
instanceId,
blob,
downloadUrl,
}: {
instanceId: string;
blob: string;
downloadUrl: string;
}) {
export function LicenceDelivery({ blob }: { instanceId: string; blob: string; downloadUrl: string }) {
const [copied, setCopied] = useState(false);
async function copy() {
await navigator.clipboard.writeText(blob);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
try {
await navigator.clipboard.writeText(blob);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Clipboard is refused without a secure context or a gesture the
// browser trusts. The blob is on screen and selectable either way,
// so this needs no error state.
}
}
const steps = [
<>
Open <code className="rounded-sm bg-accent-wash px-1">Settings Licence</code> on your
install.
Open <Code>Settings Licence</Code> on your install.
</>,
<>Paste the licence into the box and save.</>,
<>
The page reports <code className="rounded-sm bg-accent-wash px-1">Valid</code> straight
away no restart.
The page reports <Code>Valid</Code> straight away no restart.
</>,
];
return (
<section className="grid gap-3">
<h2 className="text-xl">Your licence</h2>
<div className="flex flex-wrap items-center gap-3">
<a
href={downloadUrl}
download={`vantage-${instanceId}.lic`}
className="inline-flex items-center gap-2 rounded border border-accent bg-accent px-4 py-2.5 text-[0.94rem] font-semibold text-accent-ink"
>
Download licence
</a>
<Button variant="line" type="button" onClick={copy}>
{copied ? "Copied" : "Copy to clipboard"}
</Button>
<Panel title="Your licence" meta="Paste into your install">
<div className="grid gap-2">
<div className="flex flex-wrap items-baseline justify-between gap-3">
<span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">Licence key</span>
<span className="font-mono text-[0.64rem] uppercase tracking-[0.12em] text-ink-3">{blob.length.toLocaleString()} characters</span>
</div>
<div className="relative">
{/* Dashed, because this is data to be carried somewhere else
rather than a surface to read. */}
<pre className="max-h-32 overflow-y-auto whitespace-pre-wrap break-all rounded border border-dashed border-rule bg-panel-2 p-3 pr-24 font-mono text-[0.7rem] leading-relaxed text-ink-2">
{blob}
</pre>
<button
type="button"
onClick={copy}
className="absolute right-2 top-2 rounded border border-rule bg-panel px-2.5 py-1 font-mono text-[0.66rem] uppercase tracking-[0.1em] text-ink-2 hover:border-accent hover:text-accent"
>
{copied ? "Copied" : "Copy"}
</button>
</div>
</div>
<pre className="overflow-x-auto rounded border border-dashed border-rule bg-panel-2 p-3 font-mono text-[0.72rem] text-ink-2">
{blob}
</pre>
{/* Numbered because this is an actual sequence — each step is only
possible once the one before it is done. */}
<ol className="grid gap-2">
{steps.map((body, i) => (
<li
key={i}
className="grid grid-cols-[1.6rem_1fr] gap-3 text-[0.82rem] text-ink-2"
>
<span className="h-6 rounded-sm border border-rule text-center font-mono text-[0.72rem] leading-6 text-accent">
{i + 1}
</span>
<span>{body}</span>
<li key={i} className="grid grid-cols-[1.5rem_1fr] items-start gap-3 text-[0.84rem] text-ink-2">
<span className="grid h-[1.4rem] place-items-center rounded-sm border border-rule font-mono text-[0.68rem] text-accent">{i + 1}</span>
<span className="leading-[1.4rem]">{body}</span>
</li>
))}
</ol>
</section>
</Panel>
);
}
function Code({ children }: { children: React.ReactNode }) {
return <code className="rounded-sm bg-accent-wash px-1 font-mono text-[0.8rem] text-ink">{children}</code>;
}
@@ -0,0 +1,35 @@
"use client";
import { useState } from "react";
import { ApiError, api } from "@/lib/api";
import { Button } from "@/components/Button";
/* Opens Paddle's hosted customer portal in a new tab. The account learns its
* paddle_customer_id from its first paid subscription's webhook, so this reports
* a plain message rather than erroring when there is no billing account yet. */
export function ManageBillingButton() {
const [busy, setBusy] = useState(false);
const [note, setNote] = useState<string | null>(null);
async function open() {
setBusy(true);
setNote(null);
try {
const { url } = await api.billingPortal();
window.open(url, "_blank", "noopener");
} catch (e) {
setNote(e instanceof ApiError ? e.message : "Could not open billing.");
} finally {
setBusy(false);
}
}
return (
<span className="inline-flex items-center gap-2">
<Button type="button" variant="line" onClick={open} disabled={busy}>
{busy ? "Opening…" : "Manage billing"}
</Button>
{note && <span className="text-[0.78rem] text-ink-3">{note}</span>}
</span>
);
}
+189 -118
View File
@@ -4,13 +4,43 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { ApiError, api, type InstanceRole } from "@/lib/api";
import { useSession } from "@/lib/session";
import { Button } from "@/components/Button";
import { Button, controlClass } from "@/components/Button";
import { EmptyState, Panel } from "@/components/Panel";
const ROLES: InstanceRole[] = ["owner", "admin", "member"];
/*
* What each rank actually lets someone do, in the instance rather than in the
* portal. The select used to offer three words with no statement of what they
* bought — which is a permissions control that declines to explain permissions.
*/
const ROLE_GRANTS: Record<InstanceRole, string> = {
owner: "Everything, including billing and deleting the instance.",
admin: "Manage servers, workflows, secrets and settings.",
member: "Use the instance. Cannot change settings or members.",
};
const SELECT_QUIET =
"rounded border border-transparent bg-transparent px-2 py-1 font-mono text-[0.78rem] uppercase tracking-[0.08em] text-ink-2 hover:border-rule focus:border-accent focus:text-ink focus:outline-none";
/* Same height as the Grant access button beside it — see controlClass. */
const SELECT = controlClass("bg-panel");
/*
* The access roster for one instance.
*
* Absent entirely for self-hosted instances — the backend refuses those, and a
* panel that renders controls the server will reject is a panel that lies.
*
* The row is a monogram and an address set in mono, because in this product an
* identity IS an address, and every other identifier on the screen — the
* instance UUID, the licence reference — is mono too. The role is a fact most
* of the time and a control occasionally, so it is drawn as text and only grows
* a border on hover or focus: the old row made the dropdown the loudest thing
* in it, which is backwards for a list people mostly read.
*
* Granting sits in its own strip on --panel-2 rather than as a fourth row of
* naked controls, so the roster reads as the record and the strip as the action.
*/
export function MembersPanel({ instanceId }: { instanceId: string }) {
const qc = useQueryClient();
@@ -18,6 +48,7 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
const [selected, setSelected] = useState("");
const [role, setRole] = useState<InstanceRole>("member");
const [error, setError] = useState<string | null>(null);
const [confirming, setConfirming] = useState<string | null>(null);
const members = useQuery({
queryKey: ["members", instanceId],
@@ -26,8 +57,7 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
const people = useQuery({ queryKey: ["account-users"], queryFn: api.accountUsers });
const refresh = () => qc.invalidateQueries({ queryKey: ["members", instanceId] });
const fail = (e: unknown) =>
setError(e instanceof ApiError ? e.message : "Something went wrong. Try again.");
const fail = (e: unknown) => setError(e instanceof ApiError ? e.message : "Something went wrong. Try again.");
const grant = useMutation({
mutationFn: () => api.grantMember(instanceId, selected, role),
@@ -39,140 +69,181 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
onError: fail,
});
const changeRole = useMutation({
mutationFn: (v: { uid: string; role: InstanceRole }) =>
api.setMemberRole(instanceId, v.uid, v.role),
mutationFn: (v: { uid: string; role: InstanceRole }) => api.setMemberRole(instanceId, v.uid, v.role),
onSuccess: refresh,
onError: fail,
});
const revoke = useMutation({
mutationFn: (uid: string) => api.revokeMember(instanceId, uid),
onSuccess: refresh,
onError: fail,
onSuccess: () => {
setConfirming(null);
refresh();
},
onError: (e) => {
setConfirming(null);
fail(e);
},
});
const myRole = session?.account_role;
const canManage = myRole === "owner" || myRole === "admin";
const granted = new Set((members.data ?? []).map((m) => m.customer_user_id));
const candidates = (people.data ?? []).filter(
(p) => !granted.has(p.user_id) && p.verified_at,
);
const rows = members.data ?? [];
const granted = new Set(rows.map((m) => m.customer_user_id));
const candidates = (people.data ?? []).filter((p) => !granted.has(p.user_id) && p.verified_at);
const pending = (people.data ?? []).filter((p) => !p.verified_at).length;
return (
<section className="grid gap-4 rounded border border-rule bg-panel p-5">
<div className="grid gap-1">
<h2 className="text-xl">Who can sign in</h2>
<p className="text-[0.82rem] text-ink-2">
Each person here has a real user inside this instance and signs in with their
Vantage HQ password.
</p>
<Panel title="Who can sign in" meta={rows.length ? `${rows.length} ${rows.length === 1 ? "person" : "people"}` : undefined} bodyless>
<div className="grid gap-3 px-4 pb-4 pt-3.5">
<p className="text-[0.84rem] text-ink-2">Each person here has a real user inside this instance and signs in with their Vantage HQ password.</p>
{error && (
<p role="alert" className="rounded border border-rule border-l-[3px] border-l-expired bg-panel-2 px-3.5 py-2.5 text-[0.84rem] text-ink-2">
{error}
</p>
)}
</div>
{error && <p className="text-[0.9rem] text-expired">{error}</p>}
{rows.length === 0 ? (
<EmptyState
title="Nobody else can sign in yet."
body={canManage ? "Add someone from your account below and a user is created for them inside this instance." : "An owner or admin can grant access."}
/>
) : (
<ul className="grid border-t border-rule-soft">
{/*
* Two columns on a phone — monogram and address — with the
* controls dropping to their own full-width row beneath;
* three columns from sm up, controls right-aligned. As one
* wrapping flex row the address competed with a select and
* two buttons for 320px and lost, and the confirm step put
* three more elements into the same row.
*/}
{rows.map((m) => (
<li
key={m.member_id}
className="grid grid-cols-[auto_1fr] items-center gap-x-3 gap-y-2 border-b border-rule-soft px-4 py-3 last:border-b-0 sm:grid-cols-[auto_1fr_auto]"
>
<span aria-hidden className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-accent font-mono text-[0.62rem] font-bold text-accent-ink">
{m.email.slice(0, 2).toUpperCase()}
</span>
<span className="min-w-0 break-all font-mono text-[0.84rem] sm:truncate sm:break-normal">{m.email}</span>
<ul className="grid gap-2">
{(members.data ?? []).map((m) => (
<li
key={m.member_id}
className="flex flex-wrap items-center justify-between gap-3 border-b border-rule-soft pb-2"
>
<span>{m.email}</span>
<span className="flex items-center gap-3">
{canManage ? (
<select
value={m.role}
onChange={(e) =>
changeRole.mutate({
uid: m.customer_user_id,
role: e.target.value as InstanceRole,
})
}
className="rounded border border-rule bg-panel-2 px-2 py-1 font-mono text-[0.82rem] text-ink"
>
{ROLES.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
) : (
<span className="font-mono text-[0.82rem]">{m.role}</span>
)}
{canManage && (
<button
type="button"
className="text-[0.82rem] font-semibold text-expired underline"
onClick={() => {
if (confirm(`Remove ${m.email} from this instance?`))
revoke.mutate(m.customer_user_id);
}}
>
Remove
</button>
)}
</span>
</li>
))}
{members.data?.length === 0 && (
<li className="text-ink-2">Nobody has been added yet.</li>
)}
</ul>
<div className="col-span-2 flex flex-wrap items-center gap-2 sm:col-span-1 sm:flex-nowrap sm:justify-end">
{canManage ? (
<label className="shrink-0">
<span className="sr-only">Role for {m.email}</span>
<select
value={m.role}
title={ROLE_GRANTS[m.role]}
onChange={(e) => changeRole.mutate({ uid: m.customer_user_id, role: e.target.value as InstanceRole })}
className={SELECT_QUIET}
>
{ROLES.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
</label>
) : (
<span className="shrink-0 font-mono text-[0.78rem] uppercase tracking-[0.08em] text-ink-3">{m.role}</span>
)}
{canManage &&
/*
* Confirming inline rather than through
* window.confirm(), and in the row itself rather
* than a dialog: it can say what revoking does,
* where the eye already is.
*/
(confirming === m.customer_user_id ? (
<span className="flex flex-wrap items-center gap-x-2.5 gap-y-1">
<span className="text-[0.8rem] text-ink-2">Revoke access?</span>
<button
type="button"
className="rounded border border-expired px-2 py-0.5 font-mono text-[0.7rem] uppercase tracking-[0.08em] text-expired hover:bg-expired hover:text-panel disabled:opacity-50"
disabled={revoke.isPending}
onClick={() => revoke.mutate(m.customer_user_id)}
>
{revoke.isPending ? "Revoking…" : "Revoke"}
</button>
<button type="button" className="font-mono text-[0.7rem] uppercase tracking-[0.08em] text-ink-3 hover:text-ink" onClick={() => setConfirming(null)}>
Keep
</button>
</span>
) : (
/* Quiet until intent: a row that is mostly read
should not carry a permanently red control. */
<button
type="button"
className="shrink-0 rounded border border-transparent px-2 py-0.5 font-mono text-[0.7rem] uppercase tracking-[0.08em] text-ink-3 hover:border-expired hover:text-expired"
onClick={() => {
setError(null);
setConfirming(m.customer_user_id);
}}
>
Revoke<span className="sr-only"> access for {m.email}</span>
</button>
))}
</div>
</li>
))}
</ul>
)}
{canManage && (
<form
className="flex flex-wrap items-end gap-3"
onSubmit={(e) => {
e.preventDefault();
setError(null);
if (selected) grant.mutate();
}}
>
<label className="grid gap-1.5">
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
Add someone
</span>
<select
value={selected}
onChange={(e) => setSelected(e.target.value)}
className="rounded border border-rule bg-panel-2 px-2.5 py-2 font-mono text-ink"
>
<option value="">Choose a person</option>
{candidates.map((p) => (
<option key={p.user_id} value={p.user_id}>
{p.email}
</option>
))}
</select>
</label>
<label className="grid gap-1.5">
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
Role here
</span>
<select
value={role}
onChange={(e) => setRole(e.target.value as InstanceRole)}
className="rounded border border-rule bg-panel-2 px-2.5 py-2 font-mono text-ink"
>
{ROLES.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
</label>
<Button type="submit" disabled={!selected || grant.isPending}>
{grant.isPending ? "Adding…" : "Add"}
</Button>
</form>
)}
<div className="grid gap-3 border-t border-rule bg-panel-2 px-4 py-3.5">
{/* Stacked and full width on a phone; one row from sm up.
Three controls side by side left the person select about
90px wide, which is not enough to read an address in. */}
<form
className="grid gap-3 sm:flex sm:flex-wrap sm:items-end"
onSubmit={(e) => {
e.preventDefault();
setError(null);
if (selected) grant.mutate();
}}
>
<label className="grid min-w-0 gap-1.5 sm:flex-1">
<span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">Grant access to</span>
<select value={selected} onChange={(e) => setSelected(e.target.value)} className={SELECT} disabled={candidates.length === 0}>
<option value="">{candidates.length === 0 ? "Everyone already has access" : "Choose a person…"}</option>
{candidates.map((p) => (
<option key={p.user_id} value={p.user_id}>
{p.email}
</option>
))}
</select>
</label>
<label className="grid gap-1.5">
<span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">As</span>
<select value={role} onChange={(e) => setRole(e.target.value as InstanceRole)} className={SELECT}>
{ROLES.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
</label>
<Button type="submit" disabled={!selected || grant.isPending} className="w-full justify-center sm:w-auto">
{grant.isPending ? "Granting…" : "Grant access"}
</Button>
</form>
{canManage && pending > 0 && (
<p className="text-[0.82rem] text-ink-3">
{pending} invited {pending === 1 ? "person has" : "people have"} not accepted
yet and cannot be added until they do.
</p>
{/* The chosen rank explains itself, rather than leaving three
words to be guessed at. */}
<p className="text-[0.8rem] text-ink-3">
<span className="font-mono uppercase tracking-[0.08em]">{role}</span> {ROLE_GRANTS[role]}
</p>
{pending > 0 && (
<p className="text-[0.8rem] text-ink-3">
{pending} invited {pending === 1 ? "person has" : "people have"} not accepted yet, and cannot be granted access until they do.
</p>
)}
</div>
)}
</section>
</Panel>
);
}
+5 -8
View File
@@ -1,6 +1,6 @@
/*
* The deployment failure this repo makes most often, made legible. It names the
* variable, the value baked in, and both reasons it fails unreachable from
* variable, the value baked in, and both reasons it fails unreachable from
* the browser, or missing from admin's ADMIN_ORIGIN.
*/
export function NotConnectedPanel({ url }: { url: string }) {
@@ -9,19 +9,16 @@ export function NotConnectedPanel({ url }: { url: string }) {
<h2 className="text-xl text-expired">Not connected to the licensing service</h2>
{url ? (
<p className="text-ink-2">
This build points at <code className="text-ink">ADMIN_API_URL</code> ={" "}
<code className="text-ink">{url}</code>, which did not respond.
This build points at <code className="text-ink">ADMIN_API_URL</code> = <code className="text-ink">{url}</code>, which did not respond.
</p>
) : (
<p className="text-ink-2">
<code className="text-ink">ADMIN_API_URL</code> was not set when this app was
built, so there is nowhere to send requests.
<code className="text-ink">ADMIN_API_URL</code> was not set when this app was built, so there is nowhere to send requests.
</p>
)}
<p className="text-[0.82rem] text-ink-3">
The value is baked in when the image is built and has to be reachable from your
browser, not just from the server. It also has to appear in the licensing
service&rsquo;s <code>ADMIN_ORIGIN</code>, or the browser blocks every request.
The value is baked in when the image is built and has to be reachable from your browser, not just from the server. It also has to appear in the licensing service&rsquo;s{" "}
<code>ADMIN_ORIGIN</code>, or the browser blocks every request.
</p>
</div>
);
+49
View File
@@ -0,0 +1,49 @@
/*
* Main column plus a fixed support rail.
*
* The rail is what stops a page being empty and the main column is what stops
* it being thin: an account with one instance used to render a third of a row
* of summary and nothing else. The rail carries what is true regardless of how
* many instances exist, so the page has a floor.
*
* It collapses below lg in source order, which puts the main column first on a
* phone. Nothing is hidden at any width if content only fits on a desktop it
* does not belong in the rail.
*/
export function PageFrame({ children, aside }: { children: React.ReactNode; aside?: React.ReactNode }) {
if (!aside) return <div className="grid gap-5">{children}</div>;
return (
<div className="grid items-start gap-5 lg:grid-cols-[minmax(0,1fr)_320px]">
<div className="grid min-w-0 gap-4">{children}</div>
<aside className="grid gap-3.5">{aside}</aside>
</div>
);
}
/** One card in the rail. Title is a label, not a heading you read for pleasure. */
export function RailCard({ title, count, children }: { title: string; count?: number | string; children: React.ReactNode }) {
return (
<section className="grid gap-2.5 rounded border border-rule bg-panel p-3.5">
<header className="flex items-baseline justify-between gap-2.5">
<h2 className="font-mono text-[0.66rem] font-normal uppercase tracking-[0.12em] text-ink-3">{title}</h2>
{count !== undefined && <b className="text-[0.95rem] font-extrabold tabular-nums">{count}</b>}
</header>
{children}
</section>
);
}
/** Key/value rows for the rail. Values are mono so numbers line up. */
export function RailFacts({ rows }: { rows: { label: string; value: React.ReactNode }[] }) {
return (
<dl className="grid gap-1.5">
{rows.map((r) => (
<div key={r.label} className="flex justify-between gap-2.5 text-[0.82rem]">
<dt className="text-ink-3">{r.label}</dt>
<dd className="m-0 truncate font-mono text-[0.78rem] tabular-nums text-ink">{r.value}</dd>
</div>
))}
</dl>
);
}
+96
View File
@@ -0,0 +1,96 @@
"use client";
import Link from "next/link";
import { useState } from "react";
/*
* One record-line entry. `copy` marks the value as worth lifting to the
* clipboard an instance UUID or a licence ID, the strings people paste into
* support tickets.
*/
export type RecordField = { key: string; value: string; copy?: boolean };
function CopyButton({ value }: { value: string }) {
const [done, setDone] = useState(false);
return (
<button
type="button"
// Never the thing that wraps: it is 5 characters and the value
// beside it may be 36.
onClick={async () => {
try {
await navigator.clipboard.writeText(value);
setDone(true);
setTimeout(() => setDone(false), 1200);
} catch {
// Clipboard is refused without a secure context or a user
// gesture the browser trusts. The value is on screen and
// selectable either way, so this needs no error state.
}
}}
className="shrink-0 rounded-sm border border-rule px-1.5 py-px font-mono text-[0.62rem] uppercase tracking-[0.1em] text-ink-3 hover:border-accent hover:text-accent"
>
{done ? "Copied" : "Copy"}
</button>
);
}
/*
* The page frame every screen starts with, replacing nine hand-rolled header
* blocks that each picked their own gaps and their own place for actions.
*
* The record line is the one new idea: Vantage HQ is a registry, so every screen
* is a record and records have reference numbers. Giving the reference a fixed
* slot, in mono, above the fold, means "where is the ID" stops being a per-page
* question. It costs one hairline rule.
*/
export function PageHeader({
back,
title,
subtitle,
actions,
record,
status,
}: {
back?: { href: string; label: string };
title: string;
subtitle?: React.ReactNode;
actions?: React.ReactNode;
record?: RecordField[];
status?: React.ReactNode;
}) {
return (
<header className="grid gap-3">
{back && (
<Link href={back.href} className="justify-self-start font-mono text-[0.7rem] uppercase tracking-[0.1em] text-ink-3 hover:text-accent">
&larr; {back.label}
</Link>
)}
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0">
<h1 className="text-[1.9rem]">{title}</h1>
{subtitle && <p className="mt-1 text-[0.92rem] text-ink-2">{subtitle}</p>}
</div>
{actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>}
</div>
{(record?.length || status) && (
<div className="flex flex-wrap items-center gap-x-5 gap-y-2.5 border-t border-rule pt-2.5">
{record?.map((f) => (
// min-w-0 and break-all because the commonest value here
// is a 36-character UUID with a Copy button beside it,
// which does not fit a 320px screen as one unbreakable
// token and pushed the whole page sideways.
<span key={f.key} className="flex min-w-0 items-center gap-2">
<span className="shrink-0 font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">{f.key}</span>
<span className="min-w-0 break-all font-mono text-[0.78rem] tabular-nums text-ink-2">{f.value}</span>
{f.copy && <CopyButton value={f.value} />}
</span>
))}
{status && <span className="ml-auto">{status}</span>}
</div>
)}
</header>
);
}
+91
View File
@@ -0,0 +1,91 @@
import clsx from "clsx";
/*
* The surface every screen is built from.
*
* Before this there were four panel treatments in the app: `rounded border
* border-rule bg-panel p-5` with an `<h2 className="text-xl">`, the same thing
* with `text-[0.95rem] font-medium`, a bare `<section className="space-y-2">`
* with no border at all, and a table wrapper that was a panel in everything but
* name. They were all trying to be the same object.
*
* The header is title-left, meta-right. Meta is the keyed idiom mono, small,
* tracked, dimmed because it is always a count, a scope or an identifier,
* never prose.
*/
export function Panel({
title,
meta,
actions,
tone,
children,
bodyless,
className,
}: {
title?: string;
meta?: React.ReactNode;
actions?: React.ReactNode;
/** Draws the panel's own border in a state colour. For a panel that IS the warning. */
tone?: "warn" | "expired";
children: React.ReactNode;
/** Skip the padded body — for a panel whose content is a full-bleed table. */
bodyless?: boolean;
className?: string;
}) {
const head = title || meta || actions;
return (
<section
className={clsx(
"grid overflow-hidden rounded border bg-panel",
tone === "warn" ? "border-warn" : tone === "expired" ? "border-expired" : "border-rule",
className,
)}
>
{head && (
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-rule-soft px-4 py-3">
{title && <h2 className="text-[0.95rem] font-bold tracking-[-0.01em]">{title}</h2>}
<div className="flex items-center gap-3">
{meta && <span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">{meta}</span>}
{actions}
</div>
</header>
)}
{bodyless ? children : <div className="grid gap-3.5 p-4">{children}</div>}
</section>
);
}
/*
* An aside that is part of the argument rather than beside it: the consequence
* of the action on screen, or the constraint the reader is about to hit. The
* left rule carries the tone, so the note reads as annotation and never as a
* second panel competing with the one it sits in.
*/
export function Note({ tone = "accent", children }: { tone?: "accent" | "warn" | "expired"; children: React.ReactNode }) {
return (
<p
className={clsx(
"rounded border border-rule border-l-[3px] bg-panel-2 px-3.5 py-2.5 text-[0.84rem] text-ink-2",
tone === "warn" ? "border-l-warn" : tone === "expired" ? "border-l-expired" : "border-l-accent",
)}
>
{children}
</p>
);
}
/*
* An empty screen is an invitation to act. Every one of these says what the
* thing is before offering to make one "No licences match those filters" on
* its own tells someone the filter worked, not what to do about it.
*/
export function EmptyState({ title, body, action }: { title: string; body?: React.ReactNode; action?: React.ReactNode }) {
return (
<div className="grid justify-items-center gap-2 px-5 py-12 text-center">
<p className="text-[1rem] font-bold">{title}</p>
{body && <p className="max-w-[46ch] text-[0.86rem] text-ink-2">{body}</p>}
{action && <div className="mt-2">{action}</div>}
</div>
);
}
+174
View File
@@ -0,0 +1,174 @@
"use client";
import { useMemo } from "react";
import type { CatalogueRow, Deployment, Plan, Term, Tier } from "@/lib/api";
import { featureLabel } from "@/lib/features";
export interface PlanChoice {
tier: Tier;
term: Term;
servers: number;
features: string[];
}
/* Self-hosted sells annual only. The reason is in shared/license: an offline
* licence cannot be revoked, so the term length IS the revocation window. */
function termsFor(deployment: Deployment): Term[] {
return deployment === "self_hosted" ? ["annual"] : ["monthly", "annual"];
}
/*
* PlanConfigurator is the whole of "what is this instance allowed", driven
* entirely by the plans and catalogue it is handed.
*
* A feature appears because a catalogue row offers it, and shows a price because
* that row has one. Nothing here is hardcoded per tier, which is what lets a new
* paid add-on ship as a staff edit rather than a frontend release.
*
* It saves nothing and knows nothing about who is using it. Staff mount it to
* set an entitlement; the customer purchase flow mounts the same component and
* hands it a checkout.
*/
export default function PlanConfigurator({
deployment,
value,
plans,
catalogue,
onChange,
disabled,
}: {
deployment: Deployment;
value: PlanChoice;
plans: Plan[];
catalogue: CatalogueRow[];
onChange: (next: PlanChoice) => void;
disabled?: boolean;
}) {
const available = useMemo(
() => plans.filter((p) => p.deployment === deployment && p.active),
[plans, deployment],
);
const plan = available.find((p) => p.tier === value.tier);
const rows = useMemo(
() => catalogue.filter((r) => r.deployment === deployment && r.tier === value.tier),
[catalogue, deployment, value.tier],
);
const featureRows = rows.filter((r) => r.kind === "feature");
const base = plan?.base_limits.max_servers ?? 0;
const extra = Math.max(0, value.servers - base);
const priceOf = (r: CatalogueRow) =>
r.price_ids?.sandbox?.[value.term] ?? r.price_ids?.production?.[value.term] ?? "";
return (
<div className="space-y-4">
<fieldset className="space-y-1.5">
<legend className="text-[0.78rem] text-ink-3">Tier</legend>
<div className="flex flex-wrap gap-2">
{available.map((p) => (
<button
key={p.tier}
type="button"
disabled={disabled}
onClick={() =>
onChange({
...value,
tier: p.tier,
/* Moving tier moves the floor, so clamp up
* rather than leaving an invalid count the
* backend would refuse. */
servers: Math.max(value.servers, p.base_limits.max_servers),
})
}
className={`rounded border px-3 py-1.5 text-[0.85rem] ${
p.tier === value.tier
? "border-accent text-accent"
: "border-rule text-ink-2"
}`}
>
{p.name}
</button>
))}
</div>
</fieldset>
<fieldset className="space-y-1.5">
<legend className="text-[0.78rem] text-ink-3">Term</legend>
<div className="flex flex-wrap gap-2">
{termsFor(deployment).map((t) => (
<button
key={t}
type="button"
disabled={disabled}
onClick={() => onChange({ ...value, term: t })}
className={`rounded border px-3 py-1.5 text-[0.85rem] ${
t === value.term
? "border-accent text-accent"
: "border-rule text-ink-2"
}`}
>
{t === "monthly" ? "Monthly" : "Annual"}
</button>
))}
</div>
{deployment === "self_hosted" && (
<p className="text-[0.72rem] text-ink-3">
Self-hosted is annual only.
</p>
)}
</fieldset>
<label className="block">
<span className="mb-1 block text-[0.78rem] text-ink-3">Servers</span>
<input
type="number"
min={base}
value={value.servers}
disabled={disabled}
onChange={(e) =>
onChange({ ...value, servers: Number(e.target.value) })
}
className="w-28 rounded border border-rule bg-panel px-2 py-1.5 text-[0.85rem] text-ink"
/>
<span className="ml-2 text-[0.78rem] text-ink-3">
{base} included{extra > 0 ? `, ${extra} extra` : ""}
</span>
</label>
{featureRows.length > 0 && (
<fieldset className="space-y-1.5">
<legend className="text-[0.78rem] text-ink-3">Features</legend>
{featureRows.map((r) => {
const key = r.feature_key!;
const on = value.features.includes(key);
const priced = priceOf(r) !== "";
return (
<label
key={key}
className="flex items-center gap-2 text-[0.85rem] text-ink-2"
>
<input
type="checkbox"
checked={on}
disabled={disabled}
onChange={(e) =>
onChange({
...value,
features: e.target.checked
? [...value.features, key]
: value.features.filter((f) => f !== key),
})
}
/>
<span>{featureLabel(key)}</span>
<span className="text-[0.72rem] text-ink-3">
{priced ? "paid add-on" : "included"}
</span>
</label>
);
})}
</fieldset>
)}
</div>
);
}
+9 -4
View File
@@ -1,5 +1,6 @@
import Link from "next/link";
import clsx from "clsx";
import type { ReactNode } from "react";
const TONE = {
expired: "border-l-expired text-expired",
@@ -16,7 +17,11 @@ export function Queue({
title: string;
count: number;
tone: keyof typeof TONE;
items: { label: string; href: string; meta: string }[];
/* `meta` is a node rather than a string so a queue about time can carry the
* term measurement itself. A tier name told the reader what the instance
* was; the queue is sorted by how soon it lapses, and that was the one
* figure the row did not show. */
items: { label: string; href: string; meta: ReactNode }[];
}) {
return (
<section
@@ -38,12 +43,12 @@ export function Queue({
{items.map((i) => (
<li
key={i.href}
className="flex justify-between gap-2 font-mono text-[0.72rem] text-ink-2"
className="flex items-center justify-between gap-2 font-mono text-[0.72rem] text-ink-2"
>
<Link href={i.href} className="text-accent underline">
<Link href={i.href} className="truncate text-accent underline">
{i.label}
</Link>
<span className="tabular-nums">{i.meta}</span>
<span className="shrink-0 tabular-nums">{i.meta}</span>
</li>
))}
</ul>
+22 -32
View File
@@ -6,38 +6,28 @@ import { Field } from "./Field";
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export function RelinkPanel({
used,
max,
onRelink,
error,
}: {
instanceId: string;
used: number;
max: number;
onRelink: (newId: string) => void;
error?: string;
}) {
/*
* The relink control, and only the control.
*
* It used to carry its own heading and its own "N of M relinks left this term"
* line. It now sits inside the Moves panel, which already says both a panel
* titled Moves with "2 of 3 used" in its header, wrapping a section headed
* "Moved to a new server?" that says "1 of 3 relinks left", is the same fact
* told twice in two different directions.
*
* The exhausted case still lives here rather than in the caller: it is the
* reason the button is disabled, so it belongs beside the button.
*/
export function RelinkPanel({ used, max, onRelink, error }: { instanceId: string; used: number; max: number; onRelink: (newId: string) => void; error?: string }) {
const [open, setOpen] = useState(false);
const [value, setValue] = useState("");
const remaining = Math.max(0, max - used);
const exhausted = remaining === 0;
return (
<section className="grid gap-3 border-t border-rule-soft pt-5">
<h2 className="text-xl">Moved to a new server?</h2>
<p className="text-[0.82rem] text-ink-2">
Relinking issues a replacement licence for the new install, covering the rest of
your current term.
</p>
<div className="grid gap-3">
{open && !exhausted && (
<Field
label="New instance ID"
value={value}
onChange={(e) => setValue(e.target.value)}
error={error}
hint="From Settings → Licence on the new install."
/>
<Field label="New instance ID" value={value} onChange={(e) => setValue(e.target.value)} error={error} hint="From Settings → Licence on the new install." />
)}
<div className="flex flex-wrap items-center gap-3">
<Button
@@ -46,14 +36,14 @@ export function RelinkPanel({
disabled={exhausted || (open && !UUID_RE.test(value.trim()))}
onClick={() => (open ? onRelink(value.trim()) : setOpen(true))}
>
Relink to a new install
Move to another install
</Button>
<span className="text-[0.82rem] text-ink-3">
{exhausted
? "You have used every relink for this term — contact support and we will sort it out."
: `${remaining} of ${max} relinks left this term`}
</span>
{exhausted ? (
<span className="text-[0.82rem] text-ink-3">You have used every move for this term contact support and we will sort it out.</span>
) : (
open && <span className="text-[0.82rem] text-ink-3">Relinking issues a replacement licence covering the rest of your current term.</span>
)}
</div>
</section>
</div>
);
}
+130
View File
@@ -0,0 +1,130 @@
"use client";
import { useState } from "react";
import { Button } from "./Button";
import { Field } from "./Field";
import { Note } from "./Panel";
import { ApiError, type RenameResult } from "@/lib/api";
import { baseSlug, hostFor, slugError } from "@/lib/slug";
/*
* The rename control, and only the control the same shape as RelinkPanel: an
* input that expands in place rather than a modal, because this app has no modal
* and one action with one field does not need one.
*
* The host preview is drawn from lib/slug.ts, a mirror of the Go rules. It can
* disagree with the server; the 409 that comes back is the answer that counts.
*
* movesHost is what separates a rename that moves a DNS host from one that only
* changes a label. Self-hosted instances and unprovisioned cloud placeholders
* have no address, so every word about old links breaking and signing in again
* is false for them and a preview host they will never live at is worse than
* no preview at all.
*/
export function RenamePanel({
currentName,
currentSlug,
movesHost,
onRename,
}: {
currentName: string;
currentSlug: string;
movesHost: boolean;
onRename: (name: string) => Promise<RenameResult>;
}) {
const [open, setOpen] = useState(false);
const [value, setValue] = useState(currentName);
const [error, setError] = useState<string | undefined>();
const [busy, setBusy] = useState(false);
const [done, setDone] = useState<RenameResult | undefined>();
const name = value.trim();
const derived = baseSlug(name);
const invalid = slugError(name);
// A cosmetic edit that lands on the same slug is still a rename worth doing —
// the name is what the customer reads. Only an empty or unchanged name is
// nothing to submit.
const unchanged = name === currentName.trim();
async function submit() {
setError(undefined);
setBusy(true);
try {
const res = await onRename(name);
setDone(res);
setOpen(false);
// The input is prefilled with the current name, and the current name
// is now this one. Leaving the old text in would make the next open
// look like an edit already in progress.
setValue(res.name);
} catch (err) {
setError(err instanceof ApiError ? err.message : "Rename failed. Try again.");
} finally {
setBusy(false);
}
}
// The note sits ABOVE the control rather than replacing it. A rename is not
// a one-shot action — a customer who mistypes the new name needs the panel
// back, and returning early here left them with a success message and no way
// to correct it short of a reload.
return (
<div className="grid gap-3">
{done &&
(movesHost ? (
<Note tone="warn">
<span className="grid gap-2">
<span>
This instance is now <strong>{done.name}</strong>, at{" "}
<span className="font-mono">{hostFor(done.slug)}</span>. The old address has stopped working, and
your sign-in does not follow it you will need to sign in again there.
</span>
<a
href={done.login_url || `https://${hostFor(done.slug)}`}
className="justify-self-start font-mono text-[0.78rem] text-accent underline"
>
Open {hostFor(done.slug)} &rarr;
</a>
</span>
</Note>
) : (
<Note tone="warn">
This instance is now <strong>{done.name}</strong>.
</Note>
))}
{open && (
<Field
label="Instance name"
value={value}
onChange={(e) => setValue(e.target.value)}
error={error ?? (name ? invalid : undefined)}
hint={
movesHost && name && !invalid ? (
<>
Moves to <span className="font-mono">{hostFor(derived)}</span>
{derived === currentSlug && " — the address does not change"}
</>
) : (
"Letters and digits; everything else becomes a hyphen."
)
}
/>
)}
<div className="flex flex-wrap items-center gap-3">
<Button
type="button"
variant="line"
disabled={busy || (open && (!name || Boolean(invalid) || unchanged))}
onClick={() => (open ? submit() : setOpen(true))}
>
{busy ? "Renaming…" : "Rename instance"}
</Button>
{open && movesHost && (
<span className="text-[0.82rem] text-ink-3">
Anyone signed in will need to sign in again at the new address, and links to the old one stop working.
</span>
)}
</div>
</div>
);
}
+122
View File
@@ -0,0 +1,122 @@
import clsx from "clsx";
import type { HTMLAttributes, TdHTMLAttributes, ThHTMLAttributes } from "react";
/*
* One table treatment for the whole console.
*
* There were four: billing, licences, accounts and catalogue each wrote their
* own thead, and they disagreed about the head's type size, its tracking,
* whether it sat on --panel-2, and whether numbers were tabular. Catalogue's
* heads were sentence-case body text. A registry whose columns are set four
* ways does not read as one product.
*
* The head is the keyed idiom mono, small, uppercase, widely tracked which
* is what a column head is: a key above a value, exactly as the record line is
* a key beside one.
*
* MOBILE. `stack` collapses the table into one card per row below sm, each cell
* becoming a label/value pair drawn from TD's `label`. The variants below hang
* off a `stacked` class on the <table>, so a table that does not opt in is
* untouched at every width.
*
* It is opt-in rather than automatic because a stacked row whose cells have no
* labels is worse than a scrolling one the values lose the only thing naming
* them. Customer screens stack; the staff console's wide registry tables scroll
* sideways instead, which is the right trade for eight columns read at a desk.
*/
const STACK = "max-sm:[.stacked_&]:block";
export function Table({ stack, className, children, ...props }: HTMLAttributes<HTMLTableElement> & { stack?: boolean }) {
return (
<div className="overflow-x-auto">
<table className={clsx("w-full border-collapse text-left text-[0.86rem]", stack && "stacked max-sm:block", className)} {...props}>
{children}
</table>
</div>
);
}
export function THead({ className, children, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
return (
<thead className={clsx("border-b border-rule", "max-sm:[.stacked_&]:hidden", className)} {...props}>
{children}
</thead>
);
}
export function TBody({ className, children, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
return (
<tbody className={clsx(STACK, "max-sm:[.stacked_&]:space-y-3 max-sm:[.stacked_&]:p-3", className)} {...props}>
{children}
</tbody>
);
}
export function TR({ className, children, ...props }: HTMLAttributes<HTMLTableRowElement>) {
return (
<tr
className={clsx(
"border-b border-rule-soft last:border-0 hover:bg-panel-2",
STACK,
// Plain bg-panel-2, not an opacity modifier: this app's tokens
// are whole colours rather than RGB channels, so `/40` has
// nothing to drop an alpha into. web/ stores channels precisely
// because it leans on those modifiers; this one must not.
"max-sm:[.stacked_&]:rounded max-sm:[.stacked_&]:border max-sm:[.stacked_&]:border-rule max-sm:[.stacked_&]:bg-panel-2 max-sm:[.stacked_&]:p-3",
className,
)}
{...props}
>
{children}
</tr>
);
}
interface CellProps {
/** Right-aligns the cell. For quantities and money, which read down the column. */
numeric?: boolean;
}
export function TH({ className, numeric, children, ...props }: ThHTMLAttributes<HTMLTableCellElement> & CellProps) {
return (
<th
className={clsx(
"whitespace-nowrap px-4 py-2.5 font-mono text-[0.62rem] font-normal uppercase tracking-[0.13em] text-ink-3",
numeric && "text-right",
className,
)}
{...props}
>
{children}
</th>
);
}
export function TD({ className, numeric, label, children, ...props }: TdHTMLAttributes<HTMLTableCellElement> & CellProps & { label?: string }) {
return (
<td
className={clsx(
"px-4 py-3 align-middle",
numeric && "text-right tabular-nums",
// Stacked, a cell is a label above its value and the right
// alignment that made a money column read down the page is
// meaningless, so it is dropped.
STACK,
"max-sm:[.stacked_&]:px-0 max-sm:[.stacked_&]:py-1 max-sm:[.stacked_&]:text-left",
className,
)}
{...props}
>
{label && (
<span className="mb-0.5 hidden font-mono text-[0.6rem] uppercase tracking-[0.13em] text-ink-3 max-sm:[.stacked_&]:block">{label}</span>
)}
{children}
</td>
);
}
/** The secondary line under a cell's main value — an ID, a deployment, a date. */
export function Sub({ children }: { children: React.ReactNode }) {
return <div className="text-[0.78rem] text-ink-3">{children}</div>;
}
+104
View File
@@ -0,0 +1,104 @@
import clsx from "clsx";
import { daysRemaining, formatDate, type LicenceState } from "@/lib/format";
/*
* A licence's life as a measured line: issued at the left, expiry at the right,
* today as a notch, the part you have not got yet hatched.
*
* This replaces a 1px progress rule and a "Renews 19 Aug 2026" caption. The
* date is still there, but a date alone makes the reader do the arithmetic that
* is the only question this product is ever asked when does this stop
* working. The bar answers it before they read a word.
*
* The fill takes the state's colour, so the same vocabulary the pill uses
* carries through. State is never colour alone here either: the remaining span
* is hatched rather than tinted, the notch is a hard edge, and the days-left
* figure is written out.
*/
const TONE: Record<LicenceState, string> = {
valid: "text-valid",
warn: "text-warn",
expired: "text-expired",
none: "text-accent",
};
function span(issuedAt: string, expiresAt: string) {
const start = new Date(issuedAt).getTime();
const end = new Date(expiresAt).getTime();
const total = end - start;
// A licence issued and expiring at the same instant is not a real record,
// but it must not divide by zero on the way to being rendered.
if (!Number.isFinite(total) || total <= 0) return 100;
const elapsed = Date.now() - start;
return Math.max(0, Math.min(100, (elapsed / total) * 100));
}
export function TermBar({
issuedAt,
expiresAt,
state,
className,
}: {
issuedAt: string;
expiresAt: string;
state: LicenceState;
className?: string;
}) {
const pct = span(issuedAt, expiresAt);
const days = daysRemaining(expiresAt);
const expired = days <= 0;
const remaining = expired
? `Expired ${Math.abs(days)} ${Math.abs(days) === 1 ? "day" : "days"} ago`
: `${days} ${days === 1 ? "day" : "days"} left`;
return (
<div className={clsx("grid gap-2", TONE[state], className)}>
<div className="relative h-[26px] overflow-hidden rounded-sm border border-rule bg-panel-2">
<span className="absolute inset-y-0 left-0 bg-current opacity-[0.16]" style={{ width: `${pct}%` }} />
{/* The span still to come, drawn as absence rather than as a
second colour: it is the thing being bought. */}
<span
className="absolute inset-y-0 right-0 bg-[repeating-linear-gradient(45deg,transparent_0_5px,var(--rule-soft)_5px_6px)]"
style={{ width: `${100 - pct}%` }}
/>
<span className="absolute -inset-y-px w-0.5 bg-current" style={{ left: `${pct}%` }} />
</div>
{/*
* On a phone the three ends stack, and the figure someone actually
* came for goes first wrapping a justify-between row left "9 days
* left" marooned between two dates in the middle of the stack.
*/}
<div className="grid gap-1 sm:flex sm:flex-wrap sm:items-baseline sm:justify-between sm:gap-x-4">
<span className="order-1 font-mono text-[0.74rem] font-bold tabular-nums sm:order-2">{remaining}</span>
<span className="order-2 font-mono text-[0.64rem] uppercase tracking-[0.12em] text-ink-3 sm:order-1">Issued {formatDate(issuedAt)}</span>
<span className="order-3 font-mono text-[0.64rem] uppercase tracking-[0.12em] text-ink-3">Expires {formatDate(expiresAt)}</span>
</div>
</div>
);
}
/*
* The same measurement at 56px, for a row in a ledger. Licences, Billing and
* the staff expiry queue are all lists of terms, and a list of dates cannot be
* scanned for "which of these is nearly out" a list of bars can.
*
* It carries a text alternative rather than a title: the row it sits in is
* being read, not hovered.
*/
export function TermSpark({ issuedAt, expiresAt, state }: { issuedAt: string; expiresAt: string; state: LicenceState }) {
const pct = span(issuedAt, expiresAt);
const days = daysRemaining(expiresAt);
return (
<span className={clsx("inline-flex items-center gap-2", TONE[state])}>
<span aria-hidden className="relative inline-block h-[9px] w-14 overflow-hidden rounded-sm border border-rule bg-panel-2 align-middle">
<span className="absolute inset-y-0 left-0 bg-current opacity-[0.45]" style={{ width: `${pct}%` }} />
<span className="absolute inset-y-0 w-px bg-current" style={{ left: `${pct}%` }} />
</span>
<span className="font-mono text-[0.72rem] tabular-nums">{days <= 0 ? `${Math.abs(days)}d` : `${days}d`}</span>
</span>
);
}
+140 -19
View File
@@ -61,7 +61,8 @@ const del = <T,>(path: string) => req<T>(path, { method: "DELETE" });
// --- types ---------------------------------------------------------------
export type Deployment = "cloud" | "self_hosted";
export type Tier = "free" | "professional" | "self_hosted";
export type Tier = "free" | "professional" | "enterprise";
export type Term = "monthly" | "annual";
export type InstanceStatus = "awaiting_link" | "active" | "lapsed" | "cancelled" | "deleted";
/*
@@ -103,8 +104,10 @@ export interface InstanceMember {
export interface Limits {
max_servers: number;
max_monitors: number;
max_secret_groups: number;
max_channels: number;
audit_retention_days: number;
}
export interface Account {
@@ -126,6 +129,9 @@ export interface Instance {
status: InstanceStatus;
current_license?: string;
relink_count: number;
/** Cloud only, and only until the paid checkout provisions the real row. */
placeholder?: boolean;
renamed_at?: string;
inject_failed_at?: string | null;
notices_sent?: string[];
created_at: string;
@@ -157,24 +163,101 @@ export interface Subscription {
}
export interface Plan {
deployment: Deployment;
tier: Tier;
name: string;
deployment: Deployment;
limits: Limits;
features: string[];
paddle_product_id?: string;
paddle_price_ids?: Record<string, string>;
/* The allowance BEFORE anything is bought. Not the total a metered
* dimension adds to it. */
base_limits: Limits;
base_features: string[];
support_level: string;
active: boolean;
}
export interface CustomerUser {
user_id: string;
account_id: string;
email: string;
verified_at?: string | null;
created_at: string;
export interface CatalogueRow {
kind: "base" | "limit" | "feature";
deployment: Deployment;
tier: Tier;
limit_key?: string;
feature_key?: string;
/* environment -> term -> Paddle price ID. The running PADDLE_ENV picks the
* inner map; both environments are stored so promotion is a config change
* rather than a data migration. */
price_ids?: Record<string, Partial<Record<Term, string>>>;
}
export interface EntitlementConfig {
servers: number;
features: string[];
}
export interface CheckoutOptions {
plans: Plan[];
catalogue: CatalogueRow[];
env: "sandbox" | "production";
}
/*
* lineItemsFor builds the Paddle checkout items for a configuration, client-side
* from the catalogue already fetched. It mirrors the Go catalogue.LineItems and
* its billable() exactly: base is quantity 1; the per-server unit's quantity is
* servers MINUS the plan's base allowance (never charge for the base the one
* subtraction, kept here to match the server); a feature contributes an item
* only when its row has a price in this environment/term.
*/
export function lineItemsFor(
opts: CheckoutOptions,
choice: { tier: Tier; term: Term; servers: number; features: string[] },
deployment: Deployment,
): { priceId: string; quantity: number }[] {
const env = opts.env;
const plan = opts.plans.find((p) => p.deployment === deployment && p.tier === choice.tier);
if (!plan) return [];
const rows = opts.catalogue.filter(
(r) => r.deployment === deployment && r.tier === choice.tier,
);
const priceOf = (r: CatalogueRow) => r.price_ids?.[env]?.[choice.term] ?? "";
const base = plan.base_limits.max_servers;
const items: { priceId: string; quantity: number }[] = [];
for (const r of rows) {
const id = priceOf(r);
if (r.kind === "base") {
if (id) items.push({ priceId: id, quantity: 1 });
} else if (r.kind === "limit" && r.limit_key === "max_servers") {
// -1 base is unlimited: nothing metered. Otherwise charge servers over base.
const qty = base === -1 ? 0 : choice.servers - base;
if (qty > 0 && id) items.push({ priceId: id, quantity: qty });
} else if (r.kind === "feature" && r.feature_key) {
if (choice.features.includes(r.feature_key) && id) {
items.push({ priceId: id, quantity: 1 });
}
}
}
return items;
}
export interface Entitlement {
instance_id: string;
account_id: string;
deployment: Deployment;
tier: Tier;
term: Term;
desired: EntitlementConfig;
granted: EntitlementConfig;
resolved_limits: Limits;
scheduled_change_at?: string;
granted_at: string;
updated_at: string;
}
/*
* Staff and customer screens read the SAME customer_users row, so they share one
* type. There used to be a second, narrower CustomerUser for the staff side; it
* silently stopped matching the moment account_role was added to the model, and
* a subset type cannot warn about a field it never claimed to have.
*/
export type CustomerUser = AccountUser;
export interface AuditEntry {
actor: string;
action: string;
@@ -208,6 +291,14 @@ export interface StaffInstanceResponse {
injection: { applicable: boolean; state?: InjectionState; failed_at?: string | null };
}
export interface RenameResult {
instance_id: string;
name: string;
slug: string;
/** Empty when APP_LOGIN_URL is unset on the server. */
login_url?: string;
}
// --- calls ---------------------------------------------------------------
export const api = {
@@ -216,8 +307,6 @@ export const api = {
staffLogin: (email: string, password: string) =>
post<Session>("/auth/staff/login", { email, password }),
logout: () => post<{ ok: boolean }>("/auth/logout"),
signup: (payload: { name: string; email: string; password: string; website?: string }) =>
post<{ pending: boolean }>("/auth/signup", payload),
verify: (token: string) =>
req<{ verified: boolean; needs_password?: boolean }>(
`/auth/verify?token=${encodeURIComponent(token)}`,
@@ -228,12 +317,32 @@ export const api = {
post<Instance>("/api/instances/link", { instance_id, name }),
createInstance: (name: string) => post<Instance>("/api/instances", { name }),
renewInstance: (id: string) => post<License>(`/api/instances/${id}/renew`, {}),
renameInstance: (id: string, name: string) =>
put<RenameResult>(`/api/instances/${id}/name`, { name }),
// Self-hosted Free: issue the licence on an already-linked instance.
claimFree: (id: string) => post<License>(`/api/instances/${id}/claim-free`, {}),
relink: (id: string, instance_id: string) =>
post<License>(`/api/instances/${id}/relink`, { instance_id }),
license: (id: string) => req<License & { blob?: string }>(`/api/instances/${id}/license`),
licenseBlobUrl: (id: string) => `${API_BASE}/api/instances/${id}/license/download`,
subscriptions: () => req<Subscription[]>("/api/subscriptions"),
entitlement: (id: string) =>
req<{ entitlement: Entitlement; pending: boolean }>(`/api/instances/${id}/entitlement`),
checkoutOptions: () => req<CheckoutOptions>("/api/checkout/options"),
// Paid self-hosted: links (or reuses) the install's real UUID, which the
// checkout then names. There is no placeholder to claim afterwards.
createSelfHostedCheckout: (instance_id: string, name: string) =>
post<{ instance_id: string }>("/api/instances/self-hosted", { instance_id, name }),
// Paid cloud: provisions the real instance the paid webhook then licenses.
createCloudCheckout: (name: string) =>
post<{ instance_id: string }>("/api/instances/cloud", { name }),
updateEntitlement: (
id: string,
body: { tier: Tier; term: Term; servers: number; features: string[] },
) => put<{ entitlement: Entitlement; pending: boolean }>(`/api/instances/${id}/entitlement`, body),
billingPortal: () => post<{ url: string }>("/api/billing/portal"),
accountUsers: () => req<AccountUser[]>("/api/account/users"),
invite: (email: string, role: AccountRole) =>
post<{ invited: boolean }>("/api/account/users", { email, role }),
@@ -271,14 +380,26 @@ export const api = {
post<License>(`/api/staff/instances/${id}/issue`, payload),
relink: (id: string, instance_id: string) =>
post<License>(`/api/staff/instances/${id}/relink`, { instance_id }),
renameInstance: (id: string, name: string) =>
put<RenameResult>(`/api/staff/instances/${id}/name`, { name }),
licenses: (params?: Record<string, string>) =>
req<License[]>(`/api/staff/licenses${params ? `?${new URLSearchParams(params)}` : ""}`),
plans: () => req<Plan[]>("/api/staff/plans"),
updatePlan: (tier: Tier, plan: Omit<Plan, "tier" | "deployment">) =>
req<{ updated: boolean }>(`/api/staff/plans/${tier}`, {
method: "PUT",
body: JSON.stringify(plan),
}),
updatePlan: (deployment: Deployment, tier: Tier, plan: Plan) =>
put<{ updated: boolean }>(`/api/staff/plans/${deployment}/${tier}`, plan),
catalogue: () => req<CatalogueRow[]>("/api/staff/catalogue"),
updateCatalogue: (row: CatalogueRow) =>
put<{ updated: boolean }>("/api/staff/catalogue", row),
entitlement: (id: string) =>
req<{ entitlement: Entitlement; pending: boolean }>(
`/api/staff/instances/${id}/entitlement`,
),
setEntitlement: (
id: string,
body: { tier: Tier; term: Term; servers: number; features: string[]; grant?: boolean },
) =>
put<{ entitlement: Entitlement; pending: boolean }>(
`/api/staff/instances/${id}/entitlement`, body),
audit: (accountId?: string) =>
req<AuditEntry[]>(`/api/staff/audit${accountId ? `?account_id=${accountId}` : ""}`),
injectionHealth: () =>
+27
View File
@@ -0,0 +1,27 @@
/* Human wording for licence feature keys.
*
* One place, because there were two and they disagreed: the staff configurator
* rendered every key that was not "console" as "Single sign-on", so adding a
* third feature silently mislabelled the checkbox that grants it. A map with a
* fallback degrades to the raw key, which is ugly but never wrong.
*
* Keys must match shared/license/license.go. */
export const FEATURE_LABEL: Record<string, string> = {
console: "Browser console",
oidc: "Single sign-on",
vuln_scanning: "Vulnerability scanning",
};
export const FEATURE_DESC: Record<string, string> = {
console: "In-browser SSH, RDP and VNC sessions",
oidc: "OIDC sign-in for your whole team",
vuln_scanning: "Package inventory matched against distribution security advisories",
};
export function featureLabel(key: string): string {
return FEATURE_LABEL[key] ?? key;
}
export function featureDesc(key: string): string {
return FEATURE_DESC[key] ?? "";
}

Some files were not shown because too many files have changed in this diff Show More