Compare commits
118
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f56fc2e54d | ||
|
|
dabe6fe3aa | ||
|
|
a232c74990 | ||
|
|
5e326335af | ||
|
|
14a11886ae | ||
|
|
4be7d24aec | ||
|
|
dd4ce5bb3e | ||
|
|
5ee3f14eed | ||
|
|
5dcc1bf1be | ||
|
|
facef270b7 | ||
|
|
e8c75e974d | ||
|
|
60af88525c | ||
|
|
74f6dca2f5 | ||
|
|
effd991c31 | ||
|
|
20e57d19c7 | ||
|
|
d5c8d0d0f2 | ||
|
|
2869e63d0a | ||
|
|
792b7eb211 | ||
|
|
8fd5eacec6 | ||
|
|
66140aaf58 | ||
|
|
fdfe8e8e46 | ||
|
|
4ad68e3ac4 | ||
|
|
0a86167c44 | ||
|
|
3537ec59dc | ||
|
|
8bbecd2035 | ||
|
|
c10f093cad | ||
|
|
28b138b4c3 | ||
|
|
01bb37125d | ||
|
|
2e8114c41e | ||
|
|
6832bfd7bb | ||
|
|
fbd93d0ea5 | ||
|
|
3d3be7465f | ||
|
|
c4e6ad5485 | ||
|
|
3fc726da9e | ||
|
|
73b6b548f0 | ||
|
|
ff0caf5a90 | ||
|
|
87dc9fc858 | ||
|
|
dd306e4757 | ||
|
|
fa75d70475 | ||
|
|
653bd5a755 | ||
|
|
01eda1dbb0 | ||
|
|
11ebd324ad | ||
|
|
3a6c5ebae8 | ||
|
|
34a0373eca | ||
|
|
6bf288f83e | ||
|
|
7b077905e2 | ||
|
|
2d12669f9b | ||
|
|
a05a74cf4d | ||
|
|
14b855fa9f | ||
|
|
0f5ad1d836 | ||
|
|
cca0ffbeae | ||
|
|
0c663945ee | ||
|
|
b37ed967b6 | ||
|
|
a7b9af4422 | ||
|
|
310b3ab03f | ||
|
|
fbad3e44e5 | ||
|
|
e4a3f22871 | ||
|
|
e8cc9b4ecd | ||
|
|
0b1efca737 | ||
|
|
aef5811c16 | ||
|
|
b209fed4a7 | ||
|
|
104f21d0a9 | ||
|
|
b86d9ddd86 | ||
|
|
9cf04a6940 | ||
|
|
cb90ed12fb | ||
|
|
a940390791 | ||
|
|
4e90e8619f | ||
|
|
372a8c5ddf | ||
|
|
909ddb884e | ||
|
|
1836237f82 | ||
|
|
983655d2a1 | ||
|
|
78a0a610be | ||
|
|
5f35b57268 | ||
|
|
cf318470b8 | ||
|
|
d703bbc4e8 | ||
|
|
17bcf4b5b9 | ||
|
|
da3afca7fa | ||
|
|
88f49a96ae | ||
|
|
d15ab78bd5 | ||
|
|
8eb14c1502 | ||
|
|
f8cd909828 | ||
|
|
6a3e0a29a4 | ||
|
|
c19c11e6eb | ||
|
|
85f6d47024 | ||
|
|
4175608772 | ||
|
|
24060b2c5a | ||
|
|
cefbac625c | ||
|
|
73efb206ac | ||
|
|
7fea321376 | ||
|
|
b583e9803f | ||
|
|
92ac1eeb62 | ||
|
|
242a587340 | ||
|
|
7a8e683d99 | ||
|
|
3e447fd024 | ||
|
|
6953f5e972 | ||
|
|
55526263a0 | ||
|
|
4bb7400b8e | ||
|
|
79afcc2e16 | ||
|
|
8708bd9498 | ||
|
|
58c37bf81b | ||
|
|
946a748038 | ||
|
|
481649e03f | ||
|
|
cfdc00552e | ||
|
|
b7221d8111 | ||
|
|
7e6d7074d6 | ||
|
|
268134d821 | ||
|
|
c829cc41d9 | ||
|
|
07a3756b18 | ||
|
|
769839a70d | ||
|
|
480a578deb | ||
|
|
a636a48e07 | ||
|
|
427191b14c | ||
|
|
b8fcf89ee7 | ||
|
|
64eac6dbc7 | ||
|
|
c4f1684304 | ||
|
|
8ed3bec511 | ||
|
|
58faf2e57f | ||
|
|
8e5f35f40c |
@@ -4,16 +4,88 @@ 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/|go\.work)'
|
||||
flag sitesvc '^(sitesvc/|shared/|go\.work)'
|
||||
flag admin '^(admin/|shared/|go\.work)'
|
||||
|
||||
# The three Next images use their own directory as the build
|
||||
# context, so nothing outside it can affect them.
|
||||
flag web '^web/'
|
||||
flag site '^site/'
|
||||
flag adminsite '^adminsite/'
|
||||
|
||||
- name: Log in to registry
|
||||
run: |
|
||||
@@ -22,6 +94,7 @@ jobs:
|
||||
-u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||
|
||||
- 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,27 +102,53 @@ 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 \
|
||||
--build-arg NEXT_PUBLIC_SITE_API="${{ vars.SITE_API_URL }}" \
|
||||
--build-arg NEXT_PUBLIC_CONTACT_EMAIL="support@hostxtra.co.uk" \
|
||||
--build-arg NEXT_PUBLIC_ADMIN_API_URL="${{ vars.ADMIN_API_URL }}" \
|
||||
-t "$IMAGE" \
|
||||
-f site/Dockerfile site/
|
||||
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.
|
||||
docker build -t "$IMAGE" -f sitesvc/Dockerfile .
|
||||
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.
|
||||
docker build -t "$IMAGE" -f admin/Dockerfile .
|
||||
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 }}" \
|
||||
-t "$IMAGE" \
|
||||
-f adminsite/Dockerfile adminsite/
|
||||
docker push "$IMAGE"
|
||||
|
||||
@@ -2,6 +2,7 @@ node_modules
|
||||
dist
|
||||
build
|
||||
.env
|
||||
.env.bck
|
||||
docs/*
|
||||
!docs/superpowers/
|
||||
.superpowers
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
.env
|
||||
*.lic
|
||||
@@ -0,0 +1,30 @@
|
||||
# Context is the repository root; admin depends on the shared module.
|
||||
FROM golang:1.26-alpine AS builder
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
COPY shared/go.mod shared/go.sum ./shared/
|
||||
COPY admin/go.mod admin/go.sum ./admin/
|
||||
RUN cd admin && go mod download
|
||||
|
||||
COPY shared/ ./shared/
|
||||
COPY admin/ ./admin/
|
||||
|
||||
RUN cd admin && CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/admin ./cmd
|
||||
RUN cd admin && CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/adminctl ./cmd/adminctl
|
||||
|
||||
FROM alpine:3.20 AS runner
|
||||
|
||||
RUN apk add --no-cache ca-certificates && \
|
||||
addgroup --system --gid 1001 admin && \
|
||||
adduser --system --uid 1001 --ingroup admin admin
|
||||
|
||||
COPY --from=builder /out/admin /usr/local/bin/admin
|
||||
COPY --from=builder /out/adminctl /usr/local/bin/adminctl
|
||||
|
||||
USER admin
|
||||
|
||||
EXPOSE 8083
|
||||
ENV PORT=8083
|
||||
|
||||
CMD ["/usr/local/bin/admin"]
|
||||
@@ -0,0 +1,84 @@
|
||||
// Command adminctl performs the operations that deliberately have no HTTP
|
||||
// surface.
|
||||
//
|
||||
// adminctl staff-add --email=you@example.com --name="You" --password=...
|
||||
//
|
||||
// There is no staff signup endpoint. A licensing authority that can be joined
|
||||
// over the internet is not one.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"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"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func main() {
|
||||
godotenv.Load()
|
||||
if len(os.Args) < 2 {
|
||||
fmt.Fprintln(os.Stderr, "usage: adminctl staff-add")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
fatal("configuration: %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if err := db.Connect(ctx, cfg); err != nil {
|
||||
fatal("database: %v", err)
|
||||
}
|
||||
|
||||
switch os.Args[1] {
|
||||
case "staff-add":
|
||||
staffAdd(ctx, os.Args[2:])
|
||||
default:
|
||||
fmt.Fprintln(os.Stderr, "usage: adminctl staff-add")
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
|
||||
func staffAdd(ctx context.Context, args []string) {
|
||||
fs := flag.NewFlagSet("staff-add", flag.ExitOnError)
|
||||
email := fs.String("email", "", "staff email (required)")
|
||||
name := fs.String("name", "", "display name")
|
||||
password := fs.String("password", "", "password, at least 12 characters (required)")
|
||||
fs.Parse(args)
|
||||
|
||||
if *email == "" || len(*password) < 12 {
|
||||
fatal("--email and a --password of at least 12 characters are required")
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(*password), 12)
|
||||
if err != nil {
|
||||
fatal("hash: %v", err)
|
||||
}
|
||||
|
||||
u := models.StaffUser{
|
||||
UserID: uuid.NewString(),
|
||||
Email: strings.ToLower(strings.TrimSpace(*email)),
|
||||
PasswordHash: string(hash),
|
||||
Name: *name,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
if _, err := db.Admin("staff_users").InsertOne(ctx, u); err != nil {
|
||||
fatal("create staff user: %v", err)
|
||||
}
|
||||
fmt.Printf("created staff user %s\n", u.Email)
|
||||
}
|
||||
|
||||
func fatal(format string, a ...any) {
|
||||
fmt.Fprintf(os.Stderr, format+"\n", a...)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"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"
|
||||
)
|
||||
|
||||
func main() {
|
||||
godotenv.Load()
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
log.Fatalf("configuration error: %v", err)
|
||||
}
|
||||
|
||||
licensing.SetSigningKey(cfg.SigningKey)
|
||||
api.SetAppLoginURL(cfg.AppLoginURL)
|
||||
|
||||
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,
|
||||
})
|
||||
if !mail.Enabled() {
|
||||
log.Println("warning: SMTP not configured; verification and licence emails will fail")
|
||||
}
|
||||
|
||||
auth.InitRedis(cfg.RedisAddr, cfg.RedisUsername, cfg.RedisPassword)
|
||||
pingCtx, pingCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
if err := auth.Ping(pingCtx); err != nil {
|
||||
pingCancel()
|
||||
log.Fatalf("redis: %v", err)
|
||||
}
|
||||
pingCancel()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
if err := db.Connect(ctx, cfg); err != nil {
|
||||
cancel()
|
||||
log.Fatalf("database: %v", err)
|
||||
}
|
||||
cancel()
|
||||
log.Printf("connected: admin=%s control=%s", cfg.AdminDBName, cfg.ControlDBName)
|
||||
|
||||
idxCtx, idxCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
if err := db.EnsureIndexes(idxCtx); err != nil {
|
||||
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)
|
||||
}
|
||||
idxCancel()
|
||||
|
||||
reconcileCtx, stopReconcile := context.WithCancel(context.Background())
|
||||
defer stopReconcile()
|
||||
inject.StartReconciler(reconcileCtx)
|
||||
billing.StartPlaceholderReconciler(reconcileCtx)
|
||||
hqsync.Start(reconcileCtx)
|
||||
|
||||
lifecycle.SetPortalURL(cfg.PublicURL)
|
||||
lifecycle.Start(reconcileCtx, cfg.ReapAfter)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: cfg.Addr,
|
||||
Handler: api.Routes(cfg),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ReadTimeout: 20 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
go func() {
|
||||
log.Printf("admin listening on %s", cfg.Addr)
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatalf("server error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
stopCtx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
<-stopCtx.Done()
|
||||
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer shutdownCancel()
|
||||
_ = srv.Shutdown(shutdownCtx)
|
||||
log.Println("admin stopped")
|
||||
os.Exit(0)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
module gitea.hostxtra.co.uk/mrhid6/vantage/admin
|
||||
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
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
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/hyperboloide/lk v0.0.0-20251220053519-b291812e3216 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.17.6 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/rogpeppe/go-internal v1.10.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||
github.com/xdg-go/scram v1.2.0 // indirect
|
||||
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
golang.org/x/arch v0.22.0 // indirect
|
||||
golang.org/x/net v0.56.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
replace gitea.hostxtra.co.uk/mrhid6/vantage/shared => ../shared
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hyperboloide/lk v0.0.0-20251220053519-b291812e3216 h1:Luh+sE/W2M+V0Y+jlZN7nJefLNHc4/y93xxl+rFD7k0=
|
||||
github.com/hyperboloide/lk v0.0.0-20251220053519-b291812e3216/go.mod h1:/OLW9HZj6qtQ7gWTGwuO3JrUZ+MC7I7TLRuNl14TYuo=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI=
|
||||
github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/redis/go-redis/v9 v9.20.1 h1:sfCU6A8P3dXbKyWes02uxA2baehGux9dZHfEKtsTB1w=
|
||||
github.com/redis/go-redis/v9 v9.20.1/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
|
||||
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
|
||||
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
|
||||
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||
go.mongodb.org/mongo-driver/v2 v2.8.0 h1:CxWDGQYY8QQwNjAl/aq2sfWakdnWZynnqJ9F4DhHbP8=
|
||||
go.mongodb.org/mongo-driver/v2 v2.8.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,301 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"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/billing"
|
||||
"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"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// 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(),
|
||||
})
|
||||
}
|
||||
|
||||
// createSelfHostedPlaceholder makes an instance row that exists only so a
|
||||
// checkout has something to put in custom_data. It carries no licence and is
|
||||
// flagged Placeholder until the customer pastes their install's real UUID. The
|
||||
// generated id is temporary; linking replaces the identity.
|
||||
func createSelfHostedPlaceholder(c *gin.Context) {
|
||||
s := auth.Current(c)
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || 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: body.Name,
|
||||
Deployment: license.DeploymentSelfHosted,
|
||||
Status: models.StatusAwaitingLink,
|
||||
Placeholder: true,
|
||||
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.placeholder_created", AccountID: s.AccountID,
|
||||
Target: inst.InstanceID, 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()})
|
||||
}
|
||||
|
||||
// claimPlaceholderLink binds a paid self-hosted placeholder to the customer's
|
||||
// real install UUID, then issues.
|
||||
//
|
||||
// :id is the placeholder (generated at checkout, carried in the subscription's
|
||||
// custom_data); the body carries the UUID the install actually reports. The
|
||||
// licence must bind to that real UUID (spec 1 has no unbound licence), so the
|
||||
// placeholder row's identity is rewritten to it and the subscription re-pointed,
|
||||
// then billing issues from the recorded subscription. Linking and claiming stay
|
||||
// one call here because, unlike Free, the payment already happened.
|
||||
func claimPlaceholderLink(c *gin.Context) {
|
||||
inst, ok := ownedInstance(c, c.Param("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !inst.Placeholder {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "this instance is already linked"})
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
InstanceID string `json:"instance_id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.InstanceID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "instance_id is required"})
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// The real UUID must be free across every account — the unique index on
|
||||
// instance_id is the tenant-isolation property, so refuse rather than collide.
|
||||
if n, _ := db.Admin("admin_instances").CountDocuments(ctx,
|
||||
bson.M{"instance_id": body.InstanceID}); n > 0 {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "that instance ID is already linked"})
|
||||
return
|
||||
}
|
||||
|
||||
placeholderID := inst.InstanceID
|
||||
if _, err := db.Admin("admin_instances").UpdateOne(ctx,
|
||||
bson.M{"instance_id": placeholderID},
|
||||
bson.M{"$set": bson.M{
|
||||
"instance_id": body.InstanceID,
|
||||
"status": models.StatusActive,
|
||||
"placeholder": false,
|
||||
}}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// Re-point the subscription from the placeholder id to the real UUID so
|
||||
// billing.IssueForInstance (and every later webhook) finds it.
|
||||
if _, err := db.Admin("subscriptions").UpdateMany(ctx,
|
||||
bson.M{"instance_id": placeholderID},
|
||||
bson.M{"$set": bson.M{"instance_id": body.InstanceID}}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := billing.IssueForInstance(ctx, body.InstanceID); err != nil {
|
||||
// The link stuck; issuance did not. The reconciler and a retry recover it,
|
||||
// and the customer is not blocked from linking. Surface it, do not roll back.
|
||||
c.JSON(http.StatusAccepted, gin.H{
|
||||
"instance_id": body.InstanceID,
|
||||
"warning": "linked, but licence issuance is pending: " + err.Error()})
|
||||
return
|
||||
}
|
||||
audit.Write(ctx, models.AuditEntry{
|
||||
Actor: auth.Current(c).Email, Action: "instance.placeholder_linked",
|
||||
AccountID: inst.AccountID, Target: body.InstanceID,
|
||||
Detail: "from placeholder " + placeholderID, IP: c.ClientIP()})
|
||||
c.JSON(http.StatusOK, gin.H{"instance_id": body.InstanceID})
|
||||
}
|
||||
|
||||
// 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})
|
||||
}
|
||||
@@ -0,0 +1,555 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"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"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// ownedInstance resolves an instance and confirms the session's account owns it.
|
||||
//
|
||||
// EVERY customer handler that names an instance must go through this. It returns
|
||||
// 404 for another account's instance rather than 403: a 403 confirms the
|
||||
// instance exists, which is an existence oracle over customer data.
|
||||
func ownedInstance(c *gin.Context, instanceID string) (*models.Instance, bool) {
|
||||
s := auth.Current(c)
|
||||
if s == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "sign in required"})
|
||||
return nil, false
|
||||
}
|
||||
var inst models.Instance
|
||||
err := db.Admin("admin_instances").FindOne(c.Request.Context(),
|
||||
bson.M{"instance_id": instanceID, "account_id": s.AccountID}).Decode(&inst)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return nil, false
|
||||
}
|
||||
return &inst, true
|
||||
}
|
||||
|
||||
// getMe reports who the caller is, for route guards in the UI.
|
||||
//
|
||||
// It is deliberately outside RequireCustomer/RequireStaff: the UI needs a
|
||||
// truthful 401 to redirect on, not an error page. It reveals nothing a caller
|
||||
// does not already possess, because it only ever describes their own cookie.
|
||||
func getMe(c *gin.Context) {
|
||||
s := auth.Load(c)
|
||||
if s == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "not signed in"})
|
||||
return
|
||||
}
|
||||
out := gin.H{"kind": s.Kind, "email": s.Email, "account_id": s.AccountID}
|
||||
if s.Kind == auth.KindCustomer {
|
||||
var u models.CustomerUser
|
||||
if err := db.Admin("customer_users").FindOne(c.Request.Context(),
|
||||
bson.M{"user_id": s.UserID}).Decode(&u); err == nil {
|
||||
out["account_role"] = u.AccountRole
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
func getAccount(c *gin.Context) {
|
||||
s := auth.Current(c)
|
||||
ctx := c.Request.Context()
|
||||
|
||||
var acct models.Account
|
||||
if err := db.Admin("accounts").FindOne(ctx, bson.M{"account_id": s.AccountID}).Decode(&acct); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
|
||||
cur, err := db.Admin("admin_instances").Find(ctx, bson.M{"account_id": s.AccountID})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
instances := []models.Instance{}
|
||||
if err := cur.All(ctx, &instances); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"account": acct,
|
||||
"instances": instances,
|
||||
// Sent rather than mirrored in the UI: a hardcoded 3 in TypeScript is a
|
||||
// second source of truth for a rule the backend enforces.
|
||||
"max_relinks": models.MaxRelinksPerTerm,
|
||||
})
|
||||
}
|
||||
|
||||
func linkInstance(c *gin.Context) {
|
||||
var body struct {
|
||||
InstanceID string `json:"instance_id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "instance_id is required"})
|
||||
return
|
||||
}
|
||||
|
||||
s := auth.Current(c)
|
||||
inst, err := licensing.LinkInstance(c.Request.Context(), s.AccountID, body.InstanceID, body.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
|
||||
}
|
||||
c.JSON(http.StatusCreated, inst)
|
||||
}
|
||||
|
||||
func relinkInstance(c *gin.Context) {
|
||||
inst, ok := ownedInstance(c, c.Param("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
InstanceID string `json:"instance_id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "instance_id is required"})
|
||||
return
|
||||
}
|
||||
|
||||
s := auth.Current(c)
|
||||
lic, err := licensing.Relink(c.Request.Context(), s.AccountID, inst.InstanceID, body.InstanceID, false)
|
||||
if err != nil {
|
||||
status := http.StatusBadRequest
|
||||
if errors.Is(err, licensing.ErrRelinkLimit) {
|
||||
status = http.StatusForbidden
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
deliver(c, inst, lic)
|
||||
c.JSON(http.StatusOK, lic)
|
||||
}
|
||||
|
||||
func getInstanceLicense(c *gin.Context) {
|
||||
inst, ok := ownedInstance(c, c.Param("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var lic models.License
|
||||
if err := db.Admin("licenses").FindOne(c.Request.Context(),
|
||||
bson.M{"license_id": inst.CurrentLicense}).Decode(&lic); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no licence issued yet"})
|
||||
return
|
||||
}
|
||||
// The owner gets the blob itself: it is signed public data bound to their
|
||||
// own instance, and the download endpoint hands over the same bytes. The
|
||||
// struct tag hides it, so the fields are listed explicitly.
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"license_id": lic.LicenseID, "instance_id": lic.InstanceID, "tier": lic.Tier,
|
||||
"deployment": lic.Deployment, "limits": lic.Limits, "features": lic.Features,
|
||||
"issued_at": lic.IssuedAt, "expires_at": lic.ExpiresAt, "reason": lic.Reason,
|
||||
"issued_by": lic.IssuedBy, "blob": lic.Blob,
|
||||
})
|
||||
}
|
||||
|
||||
func downloadInstanceLicense(c *gin.Context) {
|
||||
inst, ok := ownedInstance(c, c.Param("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var lic models.License
|
||||
if err := db.Admin("licenses").FindOne(c.Request.Context(),
|
||||
bson.M{"license_id": inst.CurrentLicense}).Decode(&lic); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no licence issued yet"})
|
||||
return
|
||||
}
|
||||
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="vantage-%s.lic"`, inst.InstanceID))
|
||||
c.Data(http.StatusOK, "application/octet-stream", []byte(lic.Blob+"\n"))
|
||||
}
|
||||
|
||||
func listSubscriptions(c *gin.Context) {
|
||||
s := auth.Current(c)
|
||||
cur, err := db.Admin("subscriptions").Find(c.Request.Context(), bson.M{"account_id": s.AccountID})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
subs := []models.Subscription{}
|
||||
if err := cur.All(c.Request.Context(), &subs); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
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
|
||||
// and email are deliberately NOT allowed to fail the request: the instance
|
||||
// exists and the customer can sign in, they see the licence banner, and staff
|
||||
// can issue by hand. Rolling back an instance the customer can already see would
|
||||
// be worse than shipping it unlicensed.
|
||||
func createInstance(c *gin.Context) {
|
||||
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": "name is required"})
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(body.Name)
|
||||
ctx := c.Request.Context()
|
||||
s := auth.Current(c)
|
||||
|
||||
// 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},
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not check your account"})
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"error": "this account already has a Free cloud instance"})
|
||||
return
|
||||
}
|
||||
|
||||
rec, err := provisionCloudInstance(c, name)
|
||||
if err != nil {
|
||||
cloudProvisionError(c, err)
|
||||
return
|
||||
}
|
||||
inst := rec
|
||||
|
||||
// Past this point nothing fails the request.
|
||||
lic, err := licensing.Issue(ctx, licensing.IssueInput{
|
||||
InstanceID: inst.InstanceID,
|
||||
Tier: license.TierFree,
|
||||
Term: "monthly",
|
||||
Reason: models.ReasonNew,
|
||||
IssuedBy: "self-serve",
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("ISSUE FAILED for new instance %s: %v", inst.InstanceID, err)
|
||||
c.JSON(http.StatusCreated, rec)
|
||||
return
|
||||
}
|
||||
inject.Deliver(ctx, lic)
|
||||
|
||||
if mail.Enabled() {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
rec.Tier = lic.Tier
|
||||
rec.CurrentLicense = lic.LicenseID
|
||||
c.JSON(http.StatusCreated, rec)
|
||||
}
|
||||
|
||||
// renewInstance extends a Free licence by another term.
|
||||
//
|
||||
// Renewal is manual on purpose: it is the entire reclaim signal. An instance
|
||||
// nobody renews is an instance nobody is using, and that is what makes the
|
||||
// reaper safe to run at all.
|
||||
func renewInstance(c *gin.Context) {
|
||||
inst, ok := ownedInstance(c, c.Param("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if inst.Tier != license.TierFree {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "only Free instances renew here; paid plans renew through billing"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
|
||||
var current models.License
|
||||
if err := db.Admin("licenses").FindOne(ctx,
|
||||
bson.M{"license_id": inst.CurrentLicense}).Decode(¤t); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no licence issued yet"})
|
||||
return
|
||||
}
|
||||
if time.Now().UTC().Before(current.ExpiresAt.Add(-models.RenewWindow)) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": fmt.Sprintf("this licence is not due yet; you can renew from %s",
|
||||
current.ExpiresAt.Add(-models.RenewWindow).Format("2 January 2006"))})
|
||||
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: term,
|
||||
Reason: models.ReasonRenewal,
|
||||
IssuedBy: "self-serve",
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// 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.
|
||||
if _, err := db.Admin("admin_instances").UpdateOne(ctx,
|
||||
bson.M{"instance_id": inst.InstanceID},
|
||||
bson.M{"$unset": bson.M{"notices_sent": ""}}); err != nil {
|
||||
log.Printf("renewInstance: clear notices for %s: %v", inst.InstanceID, err)
|
||||
}
|
||||
|
||||
s := auth.Current(c)
|
||||
audit.Write(ctx, models.AuditEntry{
|
||||
Actor: s.Email, Action: "instance.renewed", AccountID: s.AccountID,
|
||||
Target: inst.InstanceID, IP: c.ClientIP()})
|
||||
|
||||
if mail.Enabled() {
|
||||
if err := mail.Default.SendRenewed(s.Email, inst.Name, lic.ExpiresAt); err != nil {
|
||||
log.Printf("renewInstance: renewed email to %s: %v", s.Email, err)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, lic)
|
||||
}
|
||||
|
||||
// loginURLFor fills the {slug} template in APP_LOGIN_URL. An empty template
|
||||
// yields an empty string, and the email simply omits the link.
|
||||
func loginURLFor(slug string) string {
|
||||
if appLoginURL == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.ReplaceAll(appLoginURL, "{slug}", url.PathEscape(slug))
|
||||
}
|
||||
|
||||
// appLoginURL is set once at boot from config.
|
||||
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)
|
||||
}
|
||||
|
||||
// deliver sends a freshly issued licence where it needs to go. Cloud instances
|
||||
// are injected; self-hosted customers are emailed and can download.
|
||||
//
|
||||
// Delivery failures are logged, never returned: the licence is already recorded,
|
||||
// which is the part that must not be lost.
|
||||
func deliver(c *gin.Context, inst *models.Instance, lic *models.License) {
|
||||
if inst.Deployment == license.DeploymentCloud {
|
||||
inject.Deliver(c.Request.Context(), lic)
|
||||
return
|
||||
}
|
||||
s := auth.Current(c)
|
||||
if s != nil && mail.Enabled() {
|
||||
_ = mail.Default.SendLicense(s.Email, inst.Name, lic.Blob)
|
||||
}
|
||||
}
|
||||
@@ -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()})
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"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"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// selfHostedRefusal is the one message every membership endpoint gives for a
|
||||
// self-hosted instance. Their users live in their own deployment, which we
|
||||
// cannot see and must not write to.
|
||||
const selfHostedRefusal = "this install manages its own users; add them in Settings → Instance inside your Vantage install"
|
||||
|
||||
func listInstanceMembers(c *gin.Context) {
|
||||
inst, ok := ownedInstance(c, c.Param("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
cur, err := db.Admin("instance_members").Find(ctx,
|
||||
bson.M{"instance_id": inst.InstanceID})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
members := []models.InstanceMember{}
|
||||
if err := cur.All(ctx, &members); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, members)
|
||||
}
|
||||
|
||||
// grantInstanceMember projects an account person into a cloud instance.
|
||||
func grantInstanceMember(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
|
||||
}
|
||||
|
||||
var body struct {
|
||||
UserID string `json:"user_id"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.UserID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "user_id is required"})
|
||||
return
|
||||
}
|
||||
if body.Role == "" {
|
||||
body.Role = sharedmodels.RoleMember
|
||||
}
|
||||
if !sharedmodels.ValidRole(body.Role) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "role must be one of owner, admin, or member"})
|
||||
return
|
||||
}
|
||||
|
||||
target, ok := accountUser(c, body.UserID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if target.VerifiedAt == nil || target.PasswordHash == "" {
|
||||
// The projection copies a hash. An unverified invitee has no hash, so
|
||||
// the row would exist and be unusable — and an address nobody has
|
||||
// proven they control would hold a login inside a real instance.
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"error": "they have not accepted their invitation yet"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
s := auth.Current(c)
|
||||
|
||||
u, err := cloudprov.GrantUser(ctx, inst.InstanceID, target.Email,
|
||||
target.PasswordHash, body.Role, target.UserID)
|
||||
if err != nil {
|
||||
if errors.Is(err, provision.ErrEmailTaken) {
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"error": "that address already has a user inside this instance"})
|
||||
return
|
||||
}
|
||||
log.Printf("grant %s to %s: %v", target.Email, inst.InstanceID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not grant access"})
|
||||
return
|
||||
}
|
||||
|
||||
m := models.InstanceMember{
|
||||
MemberID: uuid.NewString(),
|
||||
AccountID: inst.AccountID,
|
||||
InstanceID: inst.InstanceID,
|
||||
CustomerUserID: target.UserID,
|
||||
ControlUserID: u.UserID,
|
||||
Role: body.Role,
|
||||
Email: target.Email,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
if _, err := db.Admin("instance_members").InsertOne(ctx, m); err != nil {
|
||||
// Unwind the projection: a control-plane login nobody on this side
|
||||
// records is a login nobody can revoke through the portal.
|
||||
if rErr := cloudprov.RevokeUser(ctx, inst.InstanceID, target.UserID); rErr != nil {
|
||||
log.Printf("grant: FAILED to unwind projection of %s in %s: %v",
|
||||
target.Email, inst.InstanceID, rErr)
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not grant access"})
|
||||
return
|
||||
}
|
||||
|
||||
audit.Write(ctx, models.AuditEntry{
|
||||
Actor: s.Email, Action: "instance_member.granted", AccountID: s.AccountID,
|
||||
Target: inst.InstanceID, Detail: target.Email + " role=" + body.Role, IP: c.ClientIP()})
|
||||
c.JSON(http.StatusCreated, m)
|
||||
}
|
||||
|
||||
// memberRow loads one membership on an instance the caller owns.
|
||||
func memberRow(c *gin.Context, instanceID, customerUserID string) (*models.InstanceMember, bool) {
|
||||
var m models.InstanceMember
|
||||
if err := db.Admin("instance_members").FindOne(c.Request.Context(), bson.M{
|
||||
"instance_id": instanceID,
|
||||
"customer_user_id": customerUserID,
|
||||
}).Decode(&m); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return nil, false
|
||||
}
|
||||
return &m, true
|
||||
}
|
||||
|
||||
func updateInstanceMemberRole(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
|
||||
}
|
||||
var body struct {
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || !sharedmodels.ValidRole(body.Role) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "role must be one of owner, admin, or member"})
|
||||
return
|
||||
}
|
||||
m, ok := memberRow(c, inst.InstanceID, c.Param("uid"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
if m.Role == sharedmodels.RoleOwner && body.Role != sharedmodels.RoleOwner {
|
||||
others, err := cloudprov.CountOtherOwners(ctx, inst.InstanceID, m.CustomerUserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if others == 0 {
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"error": "this is the instance's last owner; make someone else an owner first"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := cloudprov.SetMemberRole(ctx, inst.InstanceID, m.CustomerUserID, body.Role); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not change their role"})
|
||||
return
|
||||
}
|
||||
if _, err := db.Admin("instance_members").UpdateOne(ctx,
|
||||
bson.M{"member_id": m.MemberID},
|
||||
bson.M{"$set": bson.M{"role": body.Role}}); err != nil {
|
||||
log.Printf("member role: control plane updated but member row %s did not: %v", m.MemberID, err)
|
||||
}
|
||||
|
||||
s := auth.Current(c)
|
||||
audit.Write(ctx, models.AuditEntry{
|
||||
Actor: s.Email, Action: "instance_member.role_changed", AccountID: s.AccountID,
|
||||
Target: inst.InstanceID, Detail: m.Email + " role=" + body.Role, IP: c.ClientIP()})
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func revokeInstanceMember(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
|
||||
}
|
||||
m, ok := memberRow(c, inst.InstanceID, c.Param("uid"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
if m.Role == sharedmodels.RoleOwner {
|
||||
others, err := cloudprov.CountOtherOwners(ctx, inst.InstanceID, m.CustomerUserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if others == 0 {
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"error": "this is the instance's last owner; make someone else an owner first"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := cloudprov.RevokeUser(ctx, inst.InstanceID, m.CustomerUserID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not revoke access"})
|
||||
return
|
||||
}
|
||||
if _, err := db.Admin("instance_members").DeleteOne(ctx,
|
||||
bson.M{"member_id": m.MemberID}); err != nil {
|
||||
log.Printf("revoke: control-plane user deleted but member row %s remains: %v", m.MemberID, err)
|
||||
}
|
||||
|
||||
s := auth.Current(c)
|
||||
audit.Write(ctx, models.AuditEntry{
|
||||
Actor: s.Email, Action: "instance_member.revoked", AccountID: s.AccountID,
|
||||
Target: inst.InstanceID, Detail: m.Email, IP: c.ClientIP()})
|
||||
c.JSON(http.StatusOK, gin.H{"revoked": true})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"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/models"
|
||||
sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// listAccountUsers returns the account's people, newest last.
|
||||
//
|
||||
// Any signed-in member may read this. Knowing who your colleagues are is not
|
||||
// privileged, and hiding it would make the members panel unusable for the
|
||||
// people it is meant to inform.
|
||||
func listAccountUsers(c *gin.Context) {
|
||||
s := auth.Current(c)
|
||||
ctx := c.Request.Context()
|
||||
cur, err := db.Admin("customer_users").Find(ctx, bson.M{"account_id": s.AccountID})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
users := []models.CustomerUser{}
|
||||
if err := cur.All(ctx, &users); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, users)
|
||||
}
|
||||
|
||||
// inviteAccountUser adds a person to the account.
|
||||
//
|
||||
// It never sets a password: see CreateInvitedUser. Only an owner may invite
|
||||
// another owner, mirroring the control plane's own rule that an admin cannot
|
||||
// mint someone with more power than themselves.
|
||||
func inviteAccountUser(c *gin.Context) {
|
||||
var body struct {
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "email is required"})
|
||||
return
|
||||
}
|
||||
email := strings.ToLower(strings.TrimSpace(body.Email))
|
||||
if email == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "email is required"})
|
||||
return
|
||||
}
|
||||
if body.Role == "" {
|
||||
body.Role = models.AccountRoleMember
|
||||
}
|
||||
if !models.ValidAccountRole(body.Role) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "role must be one of owner, admin, or member"})
|
||||
return
|
||||
}
|
||||
me := auth.CurrentUser(c)
|
||||
if body.Role == models.AccountRoleOwner && me.AccountRole != models.AccountRoleOwner {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "only an owner can invite another owner"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
s := auth.Current(c)
|
||||
|
||||
// customer_users.email is globally unique, so an address already in use
|
||||
// anywhere cannot be invited here. Say so plainly: unlike signup there is
|
||||
// nothing to conceal, because the inviter already knows this address.
|
||||
if n, _ := db.Admin("customer_users").CountDocuments(ctx, bson.M{"email": email}); n > 0 {
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"error": "that address already has a Vantage HQ account"})
|
||||
return
|
||||
}
|
||||
|
||||
var acct models.Account
|
||||
if err := db.Admin("accounts").FindOne(ctx,
|
||||
bson.M{"account_id": s.AccountID}).Decode(&acct); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not read your account"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := auth.CreateInvitedUser(ctx, s.AccountID, acct.Name, email, body.Role); err != nil {
|
||||
log.Printf("invite %s to %s: %v", email, s.AccountID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not send the invitation"})
|
||||
return
|
||||
}
|
||||
|
||||
audit.Write(ctx, models.AuditEntry{
|
||||
Actor: s.Email, Action: "account_user.invited", AccountID: s.AccountID,
|
||||
Target: email, Detail: "role=" + body.Role, IP: c.ClientIP()})
|
||||
c.JSON(http.StatusCreated, gin.H{"invited": true})
|
||||
}
|
||||
|
||||
// accountUser loads one person and confirms they are on the caller's account.
|
||||
func accountUser(c *gin.Context, userID string) (*models.CustomerUser, bool) {
|
||||
s := auth.Current(c)
|
||||
var u models.CustomerUser
|
||||
if err := db.Admin("customer_users").FindOne(c.Request.Context(),
|
||||
bson.M{"user_id": userID, "account_id": s.AccountID}).Decode(&u); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return nil, false
|
||||
}
|
||||
return &u, true
|
||||
}
|
||||
|
||||
// countOtherAccountOwners counts owners of an account other than one person.
|
||||
func countOtherAccountOwners(c *gin.Context, exceptUserID string) (int64, error) {
|
||||
s := auth.Current(c)
|
||||
return db.Admin("customer_users").CountDocuments(c.Request.Context(), bson.M{
|
||||
"account_id": s.AccountID,
|
||||
"account_role": models.AccountRoleOwner,
|
||||
"user_id": bson.M{"$ne": exceptUserID},
|
||||
})
|
||||
}
|
||||
|
||||
func updateAccountUserRole(c *gin.Context) {
|
||||
var body struct {
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || !models.ValidAccountRole(body.Role) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "role must be one of owner, admin, or member"})
|
||||
return
|
||||
}
|
||||
target, ok := accountUser(c, c.Param("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
me := auth.CurrentUser(c)
|
||||
if target.UserID == me.UserID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "you cannot change your own role"})
|
||||
return
|
||||
}
|
||||
if (body.Role == models.AccountRoleOwner || target.AccountRole == models.AccountRoleOwner) &&
|
||||
me.AccountRole != models.AccountRoleOwner {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "only an owner can change owner roles"})
|
||||
return
|
||||
}
|
||||
if target.AccountRole == models.AccountRoleOwner && body.Role != models.AccountRoleOwner {
|
||||
others, err := countOtherAccountOwners(c, target.UserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if others == 0 {
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"error": "this is the account's last owner; promote someone else first"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
if _, err := db.Admin("customer_users").UpdateOne(ctx,
|
||||
bson.M{"user_id": target.UserID},
|
||||
bson.M{"$set": bson.M{"account_role": body.Role}}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
s := auth.Current(c)
|
||||
audit.Write(ctx, models.AuditEntry{
|
||||
Actor: s.Email, Action: "account_user.role_changed", AccountID: s.AccountID,
|
||||
Target: target.Email, Detail: "role=" + body.Role, IP: c.ClientIP()})
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// deleteAccountUser removes a person and every instance they hold.
|
||||
//
|
||||
// Grants go first, and the whole request is refused if any of them would strand
|
||||
// an instance with no owner. Removing the person but leaving their projected
|
||||
// rows behind would leave working logins for someone the account has removed —
|
||||
// the exact failure this endpoint exists to prevent.
|
||||
func deleteAccountUser(c *gin.Context) {
|
||||
target, ok := accountUser(c, c.Param("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
me := auth.CurrentUser(c)
|
||||
if target.UserID == me.UserID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "you cannot remove your own account"})
|
||||
return
|
||||
}
|
||||
if target.AccountRole == models.AccountRoleOwner && me.AccountRole != models.AccountRoleOwner {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "only an owner can remove another owner"})
|
||||
return
|
||||
}
|
||||
if target.AccountRole == models.AccountRoleOwner {
|
||||
others, err := countOtherAccountOwners(c, target.UserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if others == 0 {
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"error": "this is the account's last owner; promote someone else first"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
s := auth.Current(c)
|
||||
|
||||
cur, err := db.Admin("instance_members").Find(ctx,
|
||||
bson.M{"customer_user_id": target.UserID})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
members := []models.InstanceMember{}
|
||||
if err := cur.All(ctx, &members); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Check every instance BEFORE deleting anything, so a refusal leaves the
|
||||
// person exactly as they were rather than half-revoked.
|
||||
for _, m := range members {
|
||||
if m.Role != sharedmodels.RoleOwner {
|
||||
continue
|
||||
}
|
||||
others, err := cloudprov.CountOtherOwners(ctx, m.InstanceID, target.UserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if others == 0 {
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"error": "they are the last owner of an instance; give someone else that instance's owner role first"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
for _, m := range members {
|
||||
if err := cloudprov.RevokeUser(ctx, m.InstanceID, target.UserID); err != nil {
|
||||
log.Printf("deleteAccountUser: revoke %s from %s: %v", target.Email, m.InstanceID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "could not remove their instance access; nothing was deleted"})
|
||||
return
|
||||
}
|
||||
if _, err := db.Admin("instance_members").DeleteOne(ctx,
|
||||
bson.M{"member_id": m.MemberID}); err != nil {
|
||||
log.Printf("deleteAccountUser: drop member row %s: %v", m.MemberID, err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := db.Admin("customer_users").DeleteOne(ctx,
|
||||
bson.M{"user_id": target.UserID}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
audit.Write(ctx, models.AuditEntry{
|
||||
Actor: s.Email, Action: "account_user.removed", AccountID: s.AccountID,
|
||||
Target: target.Email, Detail: fmt.Sprintf("revoked %d instance(s)", len(members)),
|
||||
IP: c.ClientIP()})
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
||||
}
|
||||
|
||||
// changeAccountPassword sets one password and pushes it everywhere.
|
||||
//
|
||||
// HQ's hash is the single source of truth for every hq-sourced row, and the
|
||||
// control plane has no local password-change path for them, so there is no
|
||||
// competing writer.
|
||||
//
|
||||
// Propagation is best-effort ON PURPOSE. Failing the password change because
|
||||
// one of three instances was briefly unreachable would leave the customer with
|
||||
// the password they were trying to get rid of; hqsync repairs a stale instance
|
||||
// within fifteen minutes, which is recoverable.
|
||||
func changeAccountPassword(c *gin.Context) {
|
||||
var body struct {
|
||||
CurrentPassword string `json:"current_password"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "current and new password are required"})
|
||||
return
|
||||
}
|
||||
if len(body.NewPassword) < 12 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "choose a password of at least 12 characters"})
|
||||
return
|
||||
}
|
||||
|
||||
s := auth.Current(c)
|
||||
ctx := c.Request.Context()
|
||||
|
||||
var me models.CustomerUser
|
||||
if err := db.Admin("customer_users").FindOne(ctx,
|
||||
bson.M{"user_id": s.UserID}).Decode(&me); err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "sign in required"})
|
||||
return
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(me.PasswordHash), []byte(body.CurrentPassword)) != nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "that is not your current password"})
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(body.NewPassword), auth.BcryptCost)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not set the password"})
|
||||
return
|
||||
}
|
||||
if _, err := db.Admin("customer_users").UpdateOne(ctx,
|
||||
bson.M{"user_id": me.UserID},
|
||||
bson.M{"$set": bson.M{"password_hash": string(hash)}}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not set the password"})
|
||||
return
|
||||
}
|
||||
|
||||
pending := false
|
||||
if n, err := cloudprov.SetPasswordHash(ctx, me.UserID, string(hash)); err != nil {
|
||||
pending = true
|
||||
now := time.Now().UTC()
|
||||
log.Printf("password: propagation for %s failed, hqsync will repair: %v", me.Email, err)
|
||||
_, _ = db.Admin("customer_users").UpdateOne(ctx,
|
||||
bson.M{"user_id": me.UserID},
|
||||
bson.M{"$set": bson.M{"hq_sync_failed_at": now}})
|
||||
} else {
|
||||
log.Printf("password: %s propagated to %d instance user(s)", me.Email, n)
|
||||
_, _ = db.Admin("customer_users").UpdateOne(ctx,
|
||||
bson.M{"user_id": me.UserID},
|
||||
bson.M{"$unset": bson.M{"hq_sync_failed_at": ""}})
|
||||
}
|
||||
|
||||
audit.Write(ctx, models.AuditEntry{
|
||||
Actor: me.Email, Action: "account_user.password_changed", AccountID: s.AccountID,
|
||||
Target: me.Email, IP: c.ClientIP()})
|
||||
c.JSON(http.StatusOK, gin.H{"updated": true, "propagation_pending": pending})
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
// Package api mounts admin's HTTP surface.
|
||||
//
|
||||
// The route table is the single place scoping is guaranteed. Customer routes
|
||||
// live behind RequireCustomer and every handler that names an instance calls
|
||||
// ownedInstance. A new customer route that skips that helper is a scoping bug,
|
||||
// so keep them together and review them together.
|
||||
package api
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
func Routes(cfg config.Config) http.Handler {
|
||||
r := gin.New()
|
||||
r.Use(gin.Logger(), gin.Recovery())
|
||||
r.Use(cors(cfg.AllowedOrigins))
|
||||
|
||||
if cfg.TrustProxy {
|
||||
_ = r.SetTrustedProxies(nil)
|
||||
}
|
||||
|
||||
r.GET("/healthz", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) })
|
||||
|
||||
r.POST("/auth/staff/login", auth.HandleStaffLogin)
|
||||
// Every customer authenticates against admin's own customer_users. There is
|
||||
// deliberately no path that looks a customer up in the control plane by
|
||||
// email alone: HQ sign-in names no instance, so such a lookup could not be
|
||||
// scoped, and users.email is no longer globally unique.
|
||||
r.POST("/auth/login", auth.HandleCustomerLogin)
|
||||
r.POST("/auth/logout", auth.HandleLogout)
|
||||
r.GET("/auth/verify", auth.HandleVerify)
|
||||
r.GET("/auth/me", getMe)
|
||||
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())
|
||||
{
|
||||
cust.GET("/account", getAccount)
|
||||
|
||||
// People. Reading is open to any member; changing anything is
|
||||
// owner-or-admin, enforced per route rather than by splitting the group,
|
||||
// so the guard is visible next to the route it guards.
|
||||
cust.GET("/account/users", listAccountUsers)
|
||||
cust.POST("/account/users",
|
||||
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
|
||||
inviteAccountUser)
|
||||
cust.PUT("/account/users/:id/role",
|
||||
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
|
||||
updateAccountUserRole)
|
||||
cust.DELETE("/account/users/:id",
|
||||
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
|
||||
deleteAccountUser)
|
||||
|
||||
// Any member may change their own password — it is theirs. There is no
|
||||
// endpoint for changing anyone else's.
|
||||
cust.PUT("/account/password", changeAccountPassword)
|
||||
|
||||
cust.POST("/instances",
|
||||
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
|
||||
createInstance)
|
||||
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)
|
||||
cust.GET("/instances/:id/entitlement", getEntitlement)
|
||||
cust.GET("/checkout/options", checkoutOptions)
|
||||
cust.POST("/instances/self-hosted",
|
||||
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
|
||||
createSelfHostedPlaceholder)
|
||||
// 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.POST("/instances/:id/claim-link",
|
||||
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
|
||||
claimPlaceholderLink)
|
||||
cust.GET("/instances/:id/license", getInstanceLicense)
|
||||
cust.GET("/instances/:id/license/download", downloadInstanceLicense)
|
||||
cust.GET("/instances/:id/members", listInstanceMembers)
|
||||
cust.POST("/instances/:id/members",
|
||||
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
|
||||
grantInstanceMember)
|
||||
cust.PUT("/instances/:id/members/:uid/role",
|
||||
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
|
||||
updateInstanceMemberRole)
|
||||
cust.DELETE("/instances/:id/members/:uid",
|
||||
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
|
||||
revokeInstanceMember)
|
||||
cust.GET("/subscriptions", listSubscriptions)
|
||||
}
|
||||
|
||||
staff := r.Group("/api/staff")
|
||||
staff.Use(auth.RequireStaff())
|
||||
{
|
||||
staff.GET("/accounts", staffListAccounts)
|
||||
staff.POST("/accounts", staffCreateAccount)
|
||||
staff.GET("/accounts/:id", staffGetAccount)
|
||||
staff.POST("/accounts/:id/users", staffCreateAccountUser)
|
||||
staff.GET("/instances", staffListInstances)
|
||||
staff.POST("/instances", staffCreateInstance)
|
||||
staff.GET("/instances/:id", staffGetInstance)
|
||||
staff.GET("/subscriptions", staffListSubscriptions)
|
||||
staff.POST("/instances/:id/issue", staffIssue)
|
||||
staff.POST("/instances/:id/relink", staffRelink)
|
||||
staff.GET("/licenses", staffListLicenses)
|
||||
staff.GET("/plans", staffListPlans)
|
||||
// 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
|
||||
}
|
||||
|
||||
func cors(allowed []string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
origin := c.GetHeader("Origin")
|
||||
if origin != "" && slices.Contains(allowed, origin) {
|
||||
c.Header("Access-Control-Allow-Origin", origin)
|
||||
c.Header("Access-Control-Allow-Credentials", "true")
|
||||
c.Header("Access-Control-Allow-Headers", "Content-Type")
|
||||
c.Header("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS")
|
||||
}
|
||||
if c.Request.Method == http.MethodOptions {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,617 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"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/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"
|
||||
"github.com/gin-gonic/gin"
|
||||
"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"
|
||||
)
|
||||
|
||||
func staffListAccounts(c *gin.Context) {
|
||||
filter := bson.M{}
|
||||
if q := c.Query("q"); q != "" {
|
||||
or := []bson.M{
|
||||
{"name": bson.M{"$regex": q, "$options": "i"}},
|
||||
{"billing_email": bson.M{"$regex": q, "$options": "i"}},
|
||||
{"paddle_customer_id": q},
|
||||
}
|
||||
// A support email often contains an instance UUID and nothing else, so
|
||||
// resolve that to its owning account rather than returning nothing.
|
||||
var inst models.Instance
|
||||
if err := db.Admin("admin_instances").FindOne(c.Request.Context(),
|
||||
bson.M{"instance_id": q}).Decode(&inst); err == nil {
|
||||
or = append(or, bson.M{"account_id": inst.AccountID})
|
||||
}
|
||||
filter["$or"] = or
|
||||
}
|
||||
cur, err := db.Admin("accounts").Find(c.Request.Context(), filter,
|
||||
options.Find().SetLimit(200).SetSort(bson.D{{Key: "created_at", Value: -1}}))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
accounts := []models.Account{}
|
||||
if err := cur.All(c.Request.Context(), &accounts); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, accounts)
|
||||
}
|
||||
|
||||
func staffCreateAccount(c *gin.Context) {
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
BillingEmail string `json:"billing_email"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.Name == "" || body.BillingEmail == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name and billing_email are required"})
|
||||
return
|
||||
}
|
||||
acct := models.Account{
|
||||
AccountID: uuid.NewString(),
|
||||
Name: body.Name,
|
||||
BillingEmail: body.BillingEmail,
|
||||
Status: models.AccountActive,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
if _, err := db.Admin("accounts").InsertOne(c.Request.Context(), acct); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, acct)
|
||||
}
|
||||
|
||||
func staffGetAccount(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
var acct models.Account
|
||||
if err := db.Admin("accounts").FindOne(ctx, bson.M{"account_id": c.Param("id")}).Decode(&acct); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
instances := []models.Instance{}
|
||||
if cur, err := db.Admin("admin_instances").Find(ctx, bson.M{"account_id": acct.AccountID}); err == nil {
|
||||
_ = cur.All(ctx, &instances)
|
||||
}
|
||||
subs := []models.Subscription{}
|
||||
if cur, err := db.Admin("subscriptions").Find(ctx, bson.M{"account_id": acct.AccountID}); err == nil {
|
||||
_ = cur.All(ctx, &subs)
|
||||
}
|
||||
users := []models.CustomerUser{}
|
||||
if cur, err := db.Admin("customer_users").Find(ctx, bson.M{"account_id": acct.AccountID}); err == nil {
|
||||
_ = cur.All(ctx, &users)
|
||||
}
|
||||
entries := []models.AuditEntry{}
|
||||
if cur, err := db.Admin("admin_audit").Find(ctx, bson.M{"account_id": acct.AccountID},
|
||||
options.Find().SetLimit(100).SetSort(bson.D{{Key: "created_at", Value: -1}})); err == nil {
|
||||
_ = cur.All(ctx, &entries)
|
||||
}
|
||||
|
||||
// CustomerUser's password hash and both verify-token fields are json:"-",
|
||||
// so no secret leaves here.
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"account": acct,
|
||||
"instances": instances,
|
||||
"subscriptions": subs,
|
||||
"users": users,
|
||||
"audit": entries,
|
||||
})
|
||||
}
|
||||
|
||||
func staffListInstances(c *gin.Context) {
|
||||
filter := bson.M{}
|
||||
for param, field := range map[string]string{
|
||||
"account_id": "account_id",
|
||||
"deployment": "deployment",
|
||||
"status": "status",
|
||||
} {
|
||||
if v := c.Query(param); v != "" {
|
||||
filter[field] = v
|
||||
}
|
||||
}
|
||||
if c.Query("expiring") == "true" {
|
||||
// Instances whose licence expires within 14 days, for renewal chasing.
|
||||
//
|
||||
// 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)},
|
||||
})
|
||||
if err == nil {
|
||||
var lics []models.License
|
||||
if cur.All(c.Request.Context(), &lics) == nil {
|
||||
for _, l := range lics {
|
||||
ids = append(ids, l.InstanceID)
|
||||
}
|
||||
}
|
||||
}
|
||||
filter["instance_id"] = bson.M{"$in": ids}
|
||||
}
|
||||
|
||||
cur, err := db.Admin("admin_instances").Find(c.Request.Context(), filter,
|
||||
options.Find().SetLimit(500))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
instances := []models.Instance{}
|
||||
if err := cur.All(c.Request.Context(), &instances); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, instances)
|
||||
}
|
||||
|
||||
// staffCreateInstance attaches an instance to an account.
|
||||
//
|
||||
// For cloud, this ADOPTS an instance that already exists in the control plane —
|
||||
// the control-plane row is the source of truth for its name and slug, and this
|
||||
// refuses if no such instance exists, because an admin row pointing at nothing
|
||||
// would issue licences nobody can use.
|
||||
//
|
||||
// For self-hosted it does the same job as the customer-facing link endpoint, so
|
||||
// staff can link on a customer's behalf during support.
|
||||
//
|
||||
// This is how existing cloud instances get licensed: adopt, then issue.
|
||||
func staffCreateInstance(c *gin.Context) {
|
||||
var body struct {
|
||||
InstanceID string `json:"instance_id"`
|
||||
AccountID string `json:"account_id"`
|
||||
Deployment string `json:"deployment"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.InstanceID == "" || body.AccountID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "instance_id and account_id are required"})
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
|
||||
if n, err := db.Admin("accounts").CountDocuments(ctx, bson.M{"account_id": body.AccountID}); err != nil || n == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no such account"})
|
||||
return
|
||||
}
|
||||
|
||||
inst := models.Instance{
|
||||
InstanceID: body.InstanceID,
|
||||
AccountID: body.AccountID,
|
||||
Name: body.Name,
|
||||
Deployment: body.Deployment,
|
||||
Status: models.StatusActive,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
if body.Deployment == license.DeploymentCloud {
|
||||
var remote sharedmodels.Instance
|
||||
if err := db.Control("instances").FindOne(ctx,
|
||||
bson.M{"instance_id": body.InstanceID}).Decode(&remote); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no such cloud instance in the control plane"})
|
||||
return
|
||||
}
|
||||
inst.Name = remote.Name
|
||||
inst.Slug = remote.Slug
|
||||
}
|
||||
|
||||
if _, err := db.Admin("admin_instances").InsertOne(ctx, inst); err != nil {
|
||||
if mongo.IsDuplicateKeyError(err) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "that instance is already attached to an account"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
s := auth.Current(c)
|
||||
audit.Write(ctx, models.AuditEntry{
|
||||
Actor: s.Email, Action: "instance.attached", AccountID: body.AccountID, Target: body.InstanceID})
|
||||
c.JSON(http.StatusCreated, inst)
|
||||
}
|
||||
|
||||
// staffGetInstance is the "why did this stop working" screen's data: one
|
||||
// instance, its account, its whole licence history newest first, and whether
|
||||
// the control plane currently holds what we think it holds.
|
||||
func staffGetInstance(c *gin.Context) {
|
||||
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
|
||||
}
|
||||
|
||||
var acct models.Account
|
||||
_ = db.Admin("accounts").FindOne(ctx, bson.M{"account_id": inst.AccountID}).Decode(&acct)
|
||||
|
||||
lics := []models.License{}
|
||||
if cur, err := db.Admin("licenses").Find(ctx, bson.M{"instance_id": inst.InstanceID},
|
||||
options.Find().SetSort(bson.D{{Key: "issued_at", Value: -1}})); err == nil {
|
||||
_ = cur.All(ctx, &lics)
|
||||
}
|
||||
|
||||
// Injection state is only meaningful for cloud. For self-hosted the
|
||||
// customer holds the blob and there is nothing for us to have written.
|
||||
injection := gin.H{"applicable": inst.Deployment == license.DeploymentCloud}
|
||||
if inst.Deployment == license.DeploymentCloud {
|
||||
var remote sharedmodels.Instance
|
||||
err := db.Control("instances").FindOne(ctx,
|
||||
bson.M{"instance_id": inst.InstanceID}).Decode(&remote)
|
||||
switch {
|
||||
case err != nil:
|
||||
injection["state"] = "missing"
|
||||
case inst.CurrentLicense == "":
|
||||
injection["state"] = "none_issued"
|
||||
default:
|
||||
var current models.License
|
||||
if db.Admin("licenses").FindOne(ctx,
|
||||
bson.M{"license_id": inst.CurrentLicense}).Decode(¤t) == nil &&
|
||||
remote.LicenseBlob == current.Blob {
|
||||
injection["state"] = "current"
|
||||
} else {
|
||||
injection["state"] = "stale"
|
||||
}
|
||||
}
|
||||
injection["failed_at"] = inst.InjectFailedAt
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"instance": inst, "account": acct, "licenses": lics, "injection": injection,
|
||||
})
|
||||
}
|
||||
|
||||
// staffListSubscriptions backs the past-due queue on the dashboard.
|
||||
func staffListSubscriptions(c *gin.Context) {
|
||||
filter := bson.M{}
|
||||
if v := c.Query("status"); v != "" {
|
||||
filter["status"] = v
|
||||
}
|
||||
if v := c.Query("account_id"); v != "" {
|
||||
filter["account_id"] = v
|
||||
}
|
||||
cur, err := db.Admin("subscriptions").Find(c.Request.Context(), filter,
|
||||
options.Find().SetLimit(500))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
subs := []models.Subscription{}
|
||||
if err := cur.All(c.Request.Context(), &subs); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, subs)
|
||||
}
|
||||
|
||||
func staffIssue(c *gin.Context) {
|
||||
var body struct {
|
||||
Tier string `json:"tier"`
|
||||
Term string `json:"term"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.Tier == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "tier is required"})
|
||||
return
|
||||
}
|
||||
if body.Reason == "" {
|
||||
body.Reason = models.ReasonManual
|
||||
}
|
||||
|
||||
s := auth.Current(c)
|
||||
lic, err := licensing.Issue(c.Request.Context(), licensing.IssueInput{
|
||||
InstanceID: c.Param("id"),
|
||||
Tier: body.Tier,
|
||||
Term: body.Term,
|
||||
Reason: body.Reason,
|
||||
IssuedBy: s.Email,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var inst models.Instance
|
||||
if db.Admin("admin_instances").FindOne(c.Request.Context(),
|
||||
bson.M{"instance_id": lic.InstanceID}).Decode(&inst) == nil {
|
||||
deliver(c, &inst, lic)
|
||||
}
|
||||
c.JSON(http.StatusCreated, lic)
|
||||
}
|
||||
|
||||
// staffRelink has no attempt cap. The customer-facing limit exists to put a
|
||||
// human in front of the fourth attempt; this is that human.
|
||||
func staffRelink(c *gin.Context) {
|
||||
var body struct {
|
||||
InstanceID string `json:"instance_id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.InstanceID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "instance_id 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
|
||||
}
|
||||
|
||||
lic, err := licensing.Relink(ctx, inst.AccountID, inst.InstanceID, body.InstanceID, true)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, lic)
|
||||
}
|
||||
|
||||
func staffListLicenses(c *gin.Context) {
|
||||
filter := bson.M{}
|
||||
if v := c.Query("instance_id"); v != "" {
|
||||
filter["instance_id"] = v
|
||||
}
|
||||
if v := c.Query("account_id"); v != "" {
|
||||
filter["account_id"] = v
|
||||
}
|
||||
cur, err := db.Admin("licenses").Find(c.Request.Context(), filter,
|
||||
options.Find().SetLimit(500).SetSort(bson.D{{Key: "issued_at", Value: -1}}))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
lics := []models.License{}
|
||||
if err := cur.All(c.Request.Context(), &lics); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, lics)
|
||||
}
|
||||
|
||||
// staffBillingHealth surfaces webhook handlers that failed and paid-but-unlinked
|
||||
// placeholders, so a customer who paid and got nothing is visible rather than
|
||||
// stuck in a support queue.
|
||||
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 {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
plans := []models.Plan{}
|
||||
if err := cur.All(c.Request.Context(), &plans); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, plans)
|
||||
}
|
||||
|
||||
// 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,
|
||||
"base_limits": body.BaseLimits,
|
||||
"base_features": body.BaseFeatures.OrEmpty(),
|
||||
"support_level": body.SupportLevel,
|
||||
"active": body.Active,
|
||||
}
|
||||
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 != "" {
|
||||
filter["account_id"] = v
|
||||
}
|
||||
cur, err := db.Admin("admin_audit").Find(c.Request.Context(), filter,
|
||||
options.Find().SetLimit(500).SetSort(bson.D{{Key: "created_at", Value: -1}}))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
entries := []models.AuditEntry{}
|
||||
if err := cur.All(c.Request.Context(), &entries); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, entries)
|
||||
}
|
||||
|
||||
// staffInjectionHealth lists instances whose last injection failed. This is the
|
||||
// page to look at when a customer says their cloud instance is read-only.
|
||||
func staffInjectionHealth(c *gin.Context) {
|
||||
cur, err := db.Admin("admin_instances").Find(c.Request.Context(),
|
||||
bson.M{"inject_failed_at": bson.M{"$exists": true}})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
failed := []models.Instance{}
|
||||
if err := cur.All(c.Request.Context(), &failed); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"failed": failed, "count": len(failed)})
|
||||
}
|
||||
|
||||
// staffCreateAccountUser gives an account an HQ login.
|
||||
//
|
||||
// This is how a legacy cloud customer — one whose instance predates HQ accounts
|
||||
// — gets into the portal, alongside the manual instance attach the spec README
|
||||
// describes. It reuses CreateCustomerUser, so the row is unverified until the
|
||||
// emailed link is opened and is rolled back if that email cannot be sent.
|
||||
func staffCreateAccountUser(c *gin.Context) {
|
||||
var body struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.Email == "" || len(body.Password) < 12 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "email and a password of at least 12 characters are required"})
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
accountID := c.Param("id")
|
||||
|
||||
if n, err := db.Admin("accounts").CountDocuments(ctx,
|
||||
bson.M{"account_id": accountID}); err != nil || n == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no such account"})
|
||||
return
|
||||
}
|
||||
|
||||
email := strings.ToLower(strings.TrimSpace(body.Email))
|
||||
// Staff attaching a legacy customer are attaching the person who runs that
|
||||
// account, so they get owner.
|
||||
if err := auth.CreateCustomerUser(ctx, accountID, email, body.Password, models.AccountRoleOwner); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
s := auth.Current(c)
|
||||
audit.Write(ctx, models.AuditEntry{
|
||||
Actor: s.Email, Action: "customer_user.created", AccountID: accountID, Target: email})
|
||||
c.JSON(http.StatusCreated, gin.H{"pending": true})
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Package audit records who did what. Every issuance, link, relink and sign-in
|
||||
// attempt lands here.
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"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
|
||||
// it describes. It logs instead, loudly enough to notice.
|
||||
func Write(ctx context.Context, e models.AuditEntry) {
|
||||
e.CreatedAt = time.Now().UTC()
|
||||
if _, err := db.Admin("admin_audit").InsertOne(ctx, e); err != nil {
|
||||
log.Printf("AUDIT WRITE FAILED action=%s target=%s: %v", e.Action, e.Target, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"log"
|
||||
"net/http"
|
||||
"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"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// BcryptCost matches the control plane and sitesvc. Changing it here alone would
|
||||
// make hashes inconsistent across services that may one day compare them.
|
||||
const BcryptCost = 12
|
||||
|
||||
// VerifyWindow mirrors sitesvc's proven pattern: 32 random bytes, only the
|
||||
// SHA-256 hash stored, 24-hour expiry.
|
||||
//
|
||||
// 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.
|
||||
func CreateCustomerUser(ctx context.Context, accountID, email, password, accountRole string) error {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), BcryptCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return err
|
||||
}
|
||||
token := hex.EncodeToString(raw)
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
expiry := time.Now().UTC().Add(VerifyWindow)
|
||||
|
||||
u := models.CustomerUser{
|
||||
UserID: uuid.NewString(),
|
||||
AccountID: accountID,
|
||||
Email: strings.ToLower(strings.TrimSpace(email)),
|
||||
PasswordHash: string(hash),
|
||||
AccountRole: accountRole,
|
||||
VerifyTokenHash: hex.EncodeToString(sum[:]),
|
||||
VerifyTokenExpiry: &expiry,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
if _, err := db.Admin("customer_users").InsertOne(ctx, u); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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
|
||||
// address they just used.
|
||||
//
|
||||
// Deliberately NOT on ctx. ctx is the HTTP request's, and the most likely
|
||||
// reason we are here is that the mail server stalled until the browser
|
||||
// gave up — which cancels ctx and makes this delete a silent no-op,
|
||||
// stranding exactly the row it exists to remove. That happened in
|
||||
// production against a port-465 server.
|
||||
rbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
|
||||
defer cancel()
|
||||
if _, dErr := db.Admin("customer_users").DeleteOne(rbCtx, bson.M{"user_id": u.UserID}); dErr != nil {
|
||||
log.Printf("signup: FAILED to roll back customer_user %s (%s) after mail error: %v",
|
||||
u.UserID, u.Email, dErr)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateInvitedUser creates a passwordless, unverified member of an existing
|
||||
// account and emails them a link to set a password.
|
||||
//
|
||||
// The empty hash is load-bearing: bcrypt.CompareHashAndPassword against "" can
|
||||
// never succeed, so the row cannot sign in and cannot usefully be projected
|
||||
// into an instance until the invitee has been through /accept-invite. That is
|
||||
// also why a grant refuses an unverified user.
|
||||
func CreateInvitedUser(ctx context.Context, accountID, accountName, email, accountRole string) error {
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return err
|
||||
}
|
||||
token := hex.EncodeToString(raw)
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
expiry := time.Now().UTC().Add(VerifyWindow)
|
||||
|
||||
u := models.CustomerUser{
|
||||
UserID: uuid.NewString(),
|
||||
AccountID: accountID,
|
||||
Email: strings.ToLower(strings.TrimSpace(email)),
|
||||
AccountRole: accountRole,
|
||||
VerifyTokenHash: hex.EncodeToString(sum[:]),
|
||||
VerifyTokenExpiry: &expiry,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
if _, err := db.Admin("customer_users").InsertOne(ctx, u); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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.
|
||||
rbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
|
||||
defer cancel()
|
||||
if _, dErr := db.Admin("customer_users").DeleteOne(rbCtx, bson.M{"user_id": u.UserID}); dErr != nil {
|
||||
log.Printf("invite: FAILED to roll back customer_user %s (%s) after mail error: %v",
|
||||
u.UserID, u.Email, dErr)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleAcceptInvite consumes an invitation token and sets the password.
|
||||
//
|
||||
// Verification and password-setting are one step for an invitee, because the
|
||||
// link IS the proof of address and there is nothing to verify separately.
|
||||
func HandleAcceptInvite(c *gin.Context) {
|
||||
var body struct {
|
||||
Token string `json:"token"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.Token == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing token"})
|
||||
return
|
||||
}
|
||||
if len(body.Password) < 12 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "choose a password of at least 12 characters"})
|
||||
return
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(body.Password), BcryptCost)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not set the password"})
|
||||
return
|
||||
}
|
||||
|
||||
sum := sha256.Sum256([]byte(body.Token))
|
||||
now := time.Now().UTC()
|
||||
res, err := db.Admin("customer_users").UpdateOne(c.Request.Context(),
|
||||
bson.M{
|
||||
"verify_token_hash": hex.EncodeToString(sum[:]),
|
||||
"verify_token_expiry": bson.M{"$gt": now},
|
||||
},
|
||||
bson.M{
|
||||
"$set": bson.M{"verified_at": now, "password_hash": string(hash)},
|
||||
"$unset": bson.M{"verify_token_hash": "", "verify_token_expiry": ""},
|
||||
})
|
||||
if err != nil || res.MatchedCount == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "that link is invalid or has expired"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"accepted": true})
|
||||
}
|
||||
|
||||
// HandleSignup creates a self-hosted customer: an account, an unverified user,
|
||||
// and a verification email.
|
||||
//
|
||||
// Nothing is usable until the emailed link is opened, the same rule sitesvc
|
||||
// already proves — so an address nobody controls cannot occupy an email or
|
||||
// produce an account that can sign in.
|
||||
func HandleSignup(c *gin.Context) {
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Website string `json:"website"` // honeypot; real users never fill it
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name, email and password are required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Honeypot: answer exactly as success so a bot learns nothing.
|
||||
if strings.TrimSpace(body.Website) != "" {
|
||||
c.JSON(http.StatusCreated, gin.H{"pending": true})
|
||||
return
|
||||
}
|
||||
|
||||
email := strings.ToLower(strings.TrimSpace(body.Email))
|
||||
ctx := c.Request.Context()
|
||||
|
||||
if email == "" || len(body.Password) < 12 || strings.TrimSpace(body.Name) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name, email and a password of at least 12 characters are required"})
|
||||
return
|
||||
}
|
||||
if !allowAttempt("signup:"+email, c.ClientIP()) {
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{"error": "too many attempts, try again later"})
|
||||
return
|
||||
}
|
||||
|
||||
if n, _ := db.Admin("customer_users").CountDocuments(ctx, bson.M{"email": email}); n > 0 {
|
||||
// Same response as success. Telling a stranger the address is taken
|
||||
// confirms who has an account here.
|
||||
c.JSON(http.StatusCreated, gin.H{"pending": true})
|
||||
return
|
||||
}
|
||||
|
||||
acct := models.Account{
|
||||
AccountID: uuid.NewString(),
|
||||
Name: strings.TrimSpace(body.Name),
|
||||
BillingEmail: email,
|
||||
Status: models.AccountActive,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
if _, err := db.Admin("accounts").InsertOne(ctx, acct); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not create the account"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := CreateCustomerUser(ctx, acct.AccountID, email, body.Password, models.AccountRoleOwner); err != nil {
|
||||
// Roll the account back rather than strand one with no owner. Detached
|
||||
// from ctx for the same reason as the user rollback above: a stalled mail
|
||||
// server cancels the request, and a rollback that needs the request to
|
||||
// still be alive is a rollback that fails exactly when it is needed.
|
||||
log.Printf("signup: %s failed, rolling back account %s: %v", email, acct.AccountID, err)
|
||||
rbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
|
||||
defer cancel()
|
||||
if _, dErr := db.Admin("accounts").DeleteOne(rbCtx, bson.M{"account_id": acct.AccountID}); dErr != nil {
|
||||
log.Printf("signup: FAILED to roll back account %s: %v", acct.AccountID, dErr)
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not send the verification email"})
|
||||
return
|
||||
}
|
||||
|
||||
audit.Write(ctx, models.AuditEntry{
|
||||
Actor: email, Action: "customer.signup", AccountID: acct.AccountID, IP: c.ClientIP()})
|
||||
c.JSON(http.StatusCreated, gin.H{"pending": true})
|
||||
}
|
||||
|
||||
// HandleVerify consumes a verification token.
|
||||
func HandleVerify(c *gin.Context) {
|
||||
token := c.Query("token")
|
||||
if token == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing token"})
|
||||
return
|
||||
}
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
now := time.Now().UTC()
|
||||
ctx := c.Request.Context()
|
||||
hashed := hex.EncodeToString(sum[:])
|
||||
|
||||
// Peek first. An invited row has no password yet, so consuming its token
|
||||
// here would verify an account nobody can sign in to and burn the only
|
||||
// link that could fix it.
|
||||
var u models.CustomerUser
|
||||
if err := db.Admin("customer_users").FindOne(ctx, bson.M{
|
||||
"verify_token_hash": hashed,
|
||||
"verify_token_expiry": bson.M{"$gt": now},
|
||||
}).Decode(&u); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "that link is invalid or has expired"})
|
||||
return
|
||||
}
|
||||
if u.PasswordHash == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"verified": false, "needs_password": true})
|
||||
return
|
||||
}
|
||||
|
||||
res, err := db.Admin("customer_users").UpdateOne(ctx,
|
||||
bson.M{
|
||||
"verify_token_hash": hashed,
|
||||
"verify_token_expiry": bson.M{"$gt": now},
|
||||
},
|
||||
bson.M{
|
||||
"$set": bson.M{"verified_at": now},
|
||||
"$unset": bson.M{"verify_token_hash": "", "verify_token_expiry": ""},
|
||||
})
|
||||
if err != nil || res.MatchedCount == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "that link is invalid or has expired"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"verified": true})
|
||||
}
|
||||
|
||||
// HandleCustomerLogin authenticates a self-hosted customer.
|
||||
func HandleCustomerLogin(c *gin.Context) {
|
||||
var body struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "email and password are required"})
|
||||
return
|
||||
}
|
||||
email := strings.ToLower(strings.TrimSpace(body.Email))
|
||||
ctx := c.Request.Context()
|
||||
|
||||
if !allowAttempt(email, c.ClientIP()) {
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{"error": "too many attempts, try again later"})
|
||||
return
|
||||
}
|
||||
|
||||
reject := func(reason string) {
|
||||
audit.Write(ctx, models.AuditEntry{
|
||||
Actor: email, Action: "customer.login_failed", IP: c.ClientIP(), Detail: reason})
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": genericAuthError})
|
||||
}
|
||||
|
||||
var u models.CustomerUser
|
||||
if err := db.Admin("customer_users").FindOne(ctx, bson.M{"email": email}).Decode(&u); err != nil {
|
||||
bcrypt.CompareHashAndPassword([]byte(dummyHash), []byte(body.Password))
|
||||
reject("unknown email")
|
||||
return
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(body.Password)) != nil {
|
||||
reject("bad password")
|
||||
return
|
||||
}
|
||||
if u.VerifiedAt == nil {
|
||||
// Distinct from genericAuthError on purpose: the address is already
|
||||
// known to be theirs, so there is nothing to disclose, and "check your
|
||||
// email" is the only useful thing to say.
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "verify your email address first"})
|
||||
return
|
||||
}
|
||||
|
||||
id, err := Save(ctx, Session{
|
||||
UserID: u.UserID, Kind: KindCustomer, Email: u.Email, AccountID: u.AccountID,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "session failed"})
|
||||
return
|
||||
}
|
||||
SetCookie(c, id)
|
||||
clearAttempts(email)
|
||||
audit.Write(ctx, models.AuditEntry{
|
||||
Actor: email, Action: "customer.login", AccountID: u.AccountID, IP: c.ClientIP()})
|
||||
c.JSON(http.StatusOK, gin.H{"kind": KindCustomer, "email": u.Email})
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package auth
|
||||
|
||||
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"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
const ctxSession = "admin_session_obj"
|
||||
|
||||
// Load returns the caller's session, or nil. Exported because the session probe
|
||||
// in api/ needs to read a session without requiring one.
|
||||
func Load(c *gin.Context) *Session {
|
||||
id, err := c.Cookie(CookieName)
|
||||
if err != nil || id == "" {
|
||||
return nil
|
||||
}
|
||||
s, err := Get(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Current returns the session, or nil.
|
||||
func Current(c *gin.Context) *Session {
|
||||
if v, ok := c.Get(ctxSession); ok {
|
||||
if s, ok := v.(*Session); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func RequireStaff() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
s := Load(c)
|
||||
if s == nil || s.Kind != KindStaff {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "sign in required"})
|
||||
return
|
||||
}
|
||||
c.Set(ctxSession, s)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RequireCustomer admits both cloud and self-hosted customers. Every handler
|
||||
// behind it scopes by AccountID via the helper in api/customer.go — never by
|
||||
// remembering to filter.
|
||||
func RequireCustomer() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
s := Load(c)
|
||||
if s == nil || s.Kind != KindCustomer || s.AccountID == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "sign in required"})
|
||||
return
|
||||
}
|
||||
c.Set(ctxSession, s)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
const ctxCustomerUser = "admin_customer_user"
|
||||
|
||||
// CurrentUser returns the calling customer's own row, loaded once per request
|
||||
// by RequireAccountRole.
|
||||
//
|
||||
// It is nil behind RequireCustomer alone. A handler that needs the role must
|
||||
// sit behind RequireAccountRole, which is the only thing that loads it.
|
||||
func CurrentUser(c *gin.Context) *models.CustomerUser {
|
||||
if v, ok := c.Get(ctxCustomerUser); ok {
|
||||
if u, ok := v.(*models.CustomerUser); ok {
|
||||
return u
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RequireAccountRole admits a customer holding one of the given account roles.
|
||||
//
|
||||
// The role is read from the database on every request rather than carried in
|
||||
// the session. A session lives 24 hours; a demotion that only takes effect
|
||||
// when someone signs out again is not a demotion.
|
||||
func RequireAccountRole(roles ...string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
s := Current(c)
|
||||
if s == nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "sign in required"})
|
||||
return
|
||||
}
|
||||
var u models.CustomerUser
|
||||
if err := db.Admin("customer_users").FindOne(c.Request.Context(),
|
||||
bson.M{"user_id": s.UserID}).Decode(&u); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "sign in required"})
|
||||
return
|
||||
}
|
||||
if !slices.Contains(roles, u.AccountRole) {
|
||||
// 403 rather than 404 here: this is the caller's OWN account, so
|
||||
// there is no existence to disclose — the 404 rule protects other
|
||||
// accounts' resources, not the caller's view of their own.
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
||||
"error": "your account role does not allow this"})
|
||||
return
|
||||
}
|
||||
c.Set(ctxCustomerUser, &u)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Thresholds from spec 3: 5 attempts per email per 15 minutes, 20 per IP per
|
||||
// hour. The email limit stops a targeted attack on one account; the IP limit
|
||||
// stops a spray across many.
|
||||
const (
|
||||
emailLimit = 5
|
||||
emailWindow = 15 * time.Minute
|
||||
ipLimit = 20
|
||||
ipWindow = time.Hour
|
||||
)
|
||||
|
||||
var (
|
||||
attemptMu sync.Mutex
|
||||
byEmail = map[string][]time.Time{}
|
||||
byIP = map[string][]time.Time{}
|
||||
)
|
||||
|
||||
func prune(in []time.Time, cutoff time.Time) []time.Time {
|
||||
out := in[:0]
|
||||
for _, t := range in {
|
||||
if t.After(cutoff) {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func allowAttempt(email, ip string) bool {
|
||||
now := time.Now()
|
||||
|
||||
attemptMu.Lock()
|
||||
defer attemptMu.Unlock()
|
||||
|
||||
byEmail[email] = prune(byEmail[email], now.Add(-emailWindow))
|
||||
byIP[ip] = prune(byIP[ip], now.Add(-ipWindow))
|
||||
|
||||
if len(byEmail[email]) >= emailLimit || len(byIP[ip]) >= ipLimit {
|
||||
return false
|
||||
}
|
||||
byEmail[email] = append(byEmail[email], now)
|
||||
byIP[ip] = append(byIP[ip], now)
|
||||
return true
|
||||
}
|
||||
|
||||
func clearAttempts(email string) {
|
||||
attemptMu.Lock()
|
||||
delete(byEmail, email)
|
||||
attemptMu.Unlock()
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// Package auth holds admin's three identities: staff, cloud customers and
|
||||
// self-hosted customers. All three share one session store and one cookie.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
const (
|
||||
CookieName = "admin_session"
|
||||
SessionTTL = 24 * time.Hour
|
||||
KindStaff = "staff"
|
||||
KindCustomer = "customer"
|
||||
)
|
||||
|
||||
type Session struct {
|
||||
UserID string `json:"user_id"`
|
||||
Kind string `json:"kind"`
|
||||
Email string `json:"email"`
|
||||
AccountID string `json:"account_id,omitempty"` // customers only
|
||||
}
|
||||
|
||||
var rdb *redis.Client
|
||||
|
||||
// InitRedis connects the session store.
|
||||
//
|
||||
// Username and password may both be empty for an unauthenticated instance. For
|
||||
// a legacy `requirepass` Redis, pass the password with an empty username —
|
||||
// go-redis then sends AUTH with one argument instead of two.
|
||||
func InitRedis(addr, username, password string) {
|
||||
rdb = redis.NewClient(&redis.Options{
|
||||
Addr: addr,
|
||||
Username: username,
|
||||
Password: password,
|
||||
})
|
||||
}
|
||||
|
||||
func Ping(ctx context.Context) error { return rdb.Ping(ctx).Err() }
|
||||
|
||||
func newID() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func Save(ctx context.Context, s Session) (string, error) {
|
||||
id, err := newID()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
body, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := rdb.Set(ctx, "admin_session:"+id, body, SessionTTL).Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func Get(ctx context.Context, id string) (*Session, error) {
|
||||
body, err := rdb.Get(ctx, "admin_session:"+id).Bytes()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var s Session
|
||||
if err := json.Unmarshal(body, &s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func Destroy(ctx context.Context, id string) { rdb.Del(ctx, "admin_session:"+id) }
|
||||
|
||||
func SetCookie(c *gin.Context, id string) {
|
||||
http.SetCookie(c.Writer, &http.Cookie{
|
||||
Name: CookieName,
|
||||
Value: id,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: int(SessionTTL.Seconds()),
|
||||
})
|
||||
}
|
||||
|
||||
func ClearCookie(c *gin.Context) {
|
||||
http.SetCookie(c.Writer, &http.Cookie{
|
||||
Name: CookieName, Value: "", Path: "/", HttpOnly: true, Secure: true, MaxAge: -1,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package auth
|
||||
|
||||
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"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// genericAuthError is returned for every failure mode — unknown email, wrong
|
||||
// password, wrong role. Distinguishing them would confirm which addresses have
|
||||
// accounts.
|
||||
const genericAuthError = "email or password is incorrect"
|
||||
|
||||
func HandleStaffLogin(c *gin.Context) {
|
||||
var body struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "email and password are required"})
|
||||
return
|
||||
}
|
||||
email := strings.ToLower(strings.TrimSpace(body.Email))
|
||||
|
||||
if !allowAttempt(email, c.ClientIP()) {
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{"error": "too many attempts, try again later"})
|
||||
return
|
||||
}
|
||||
|
||||
var u models.StaffUser
|
||||
err := db.Admin("staff_users").FindOne(c.Request.Context(), bson.M{"email": email}).Decode(&u)
|
||||
if err != nil {
|
||||
// Spend the same work as a real comparison so timing does not
|
||||
// distinguish "no such user" from "wrong password".
|
||||
bcrypt.CompareHashAndPassword([]byte(dummyHash), []byte(body.Password))
|
||||
audit.Write(c.Request.Context(), models.AuditEntry{
|
||||
Actor: email, Action: "staff.login_failed", IP: c.ClientIP(), Detail: "unknown email"})
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": genericAuthError})
|
||||
return
|
||||
}
|
||||
|
||||
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(body.Password)) != nil {
|
||||
audit.Write(c.Request.Context(), models.AuditEntry{
|
||||
Actor: email, Action: "staff.login_failed", IP: c.ClientIP(), Detail: "bad password"})
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": genericAuthError})
|
||||
return
|
||||
}
|
||||
|
||||
id, err := Save(c.Request.Context(), Session{UserID: u.UserID, Kind: KindStaff, Email: u.Email})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "session failed"})
|
||||
return
|
||||
}
|
||||
SetCookie(c, id)
|
||||
clearAttempts(email)
|
||||
audit.Write(c.Request.Context(), models.AuditEntry{
|
||||
Actor: email, Action: "staff.login", IP: c.ClientIP()})
|
||||
c.JSON(http.StatusOK, gin.H{"kind": KindStaff, "email": u.Email, "name": u.Name})
|
||||
}
|
||||
|
||||
// dummyHash is a valid bcrypt hash of a random value, compared against when no
|
||||
// user exists so the timing profile matches.
|
||||
const dummyHash = "$2a$12$C6UzMDM.H6dfI/f/IKcEe.6qGZoAZQFtQmOoLmC5PbfW1uMh1Sv2u"
|
||||
|
||||
func HandleLogout(c *gin.Context) {
|
||||
if id, err := c.Cookie(CookieName); err == nil && id != "" {
|
||||
Destroy(c.Request.Context(), id)
|
||||
}
|
||||
ClearCookie(c)
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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/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)
|
||||
}
|
||||
|
||||
sub := models.Subscription{
|
||||
AccountID: d.CustomData.AccountID,
|
||||
InstanceID: d.CustomData.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}})
|
||||
}
|
||||
|
||||
var inst models.Instance
|
||||
if err := db.Admin("admin_instances").FindOne(ctx,
|
||||
bson.M{"instance_id": d.CustomData.InstanceID}).Decode(&inst); err != nil {
|
||||
return fmt.Errorf("subscription %s names unknown instance %s: %w",
|
||||
d.ID, d.CustomData.InstanceID, err)
|
||||
}
|
||||
|
||||
// Placeholders are the payment-first path: the instance does not exist until
|
||||
// this confirmed-payment event. A cloud placeholder is provisioned here and
|
||||
// then issued (first term). A self-hosted placeholder has no UUID to bind to
|
||||
// until the customer pastes their install's — its subscription is recorded and
|
||||
// the link endpoint issues later.
|
||||
reason := models.ReasonEntitlementChange
|
||||
if inst.Placeholder {
|
||||
if inst.Deployment != license.DeploymentCloud {
|
||||
return nil
|
||||
}
|
||||
provisioned, err := completeCloudPlaceholder(ctx, &inst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
inst = *provisioned
|
||||
reason = models.ReasonNew
|
||||
}
|
||||
|
||||
return promoteAndIssue(ctx, &inst, match, reason)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// IssueForInstance issues from an instance's recorded subscription. Called when
|
||||
// a self-hosted customer finally links a placeholder they have already paid for.
|
||||
func IssueForInstance(ctx context.Context, instanceID string) error {
|
||||
var sub models.Subscription
|
||||
if err := db.Admin("subscriptions").FindOne(ctx,
|
||||
bson.M{"instance_id": instanceID, "status": models.SubActive}).Decode(&sub); err != nil {
|
||||
return fmt.Errorf("no active subscription for %s: %w", instanceID, err)
|
||||
}
|
||||
var inst models.Instance
|
||||
if err := db.Admin("admin_instances").FindOne(ctx,
|
||||
bson.M{"instance_id": instanceID}).Decode(&inst); err != nil {
|
||||
return err
|
||||
}
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
return promoteAndIssue(ctx, &inst, match, models.ReasonNew)
|
||||
}
|
||||
|
||||
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 {
|
||||
var inst models.Instance
|
||||
if err := db.Admin("admin_instances").FindOne(ctx,
|
||||
bson.M{"instance_id": instanceID}).Decode(&inst); err != nil || inst.Name == "" {
|
||||
return "your instance"
|
||||
}
|
||||
return inst.Name
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
// Package cloudprov provisions cloud instances in the control plane.
|
||||
//
|
||||
// This is admin's second and final write path into the control-plane database,
|
||||
// alongside inject. It writes `instances` and `users` and nothing else. A third
|
||||
// write target, or a write to any other collection from here, is a design change
|
||||
// and not a refactor — see the spec's "Admin's control-plane write boundary".
|
||||
//
|
||||
// Every function here is called from a customer request, so each one leaves the
|
||||
// control plane in a consistent state or not at all: the caller unwinds in
|
||||
// reverse order on failure, and RollbackInstance refuses to delete an instance
|
||||
// that has users.
|
||||
package cloudprov
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// CreateInstance creates a control-plane instance and its owner.
|
||||
//
|
||||
// The owner's password hash is COPIED from the HQ account rather than shared.
|
||||
// HQ remains the single source of truth: a password change there copies the new
|
||||
// hash to every projected row (see SetPasswordHash), and hqsync repairs any that
|
||||
// a failed write left stale. The control plane has no local password-change path
|
||||
// for an hq-sourced row, so there is no competing writer.
|
||||
//
|
||||
// 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) {
|
||||
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 {
|
||||
if rbErr := provision.RollbackInstance(ctx, db.ControlDB(), inst.InstanceID); rbErr != nil {
|
||||
return nil, fmt.Errorf("create owner: %w (and rollback failed: %v)", err, rbErr)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// hq_user_id is what phase 3 uses to find every row projected from one HQ
|
||||
// user when its password changes. Set at creation so the owner is not a
|
||||
// special case later.
|
||||
if _, err := db.Control("users").UpdateOne(ctx,
|
||||
bson.M{"user_id": u.UserID},
|
||||
bson.M{"$set": bson.M{"hq_user_id": hqUserID}}); err != nil {
|
||||
return nil, fmt.Errorf("set hq_user_id: %w", err)
|
||||
}
|
||||
|
||||
return inst, nil
|
||||
}
|
||||
|
||||
// DeleteUser removes one control-plane user. Used only to unwind a failed
|
||||
// provision.
|
||||
func DeleteUser(ctx context.Context, instanceID, userID string) error {
|
||||
_, err := db.Control("users").DeleteOne(ctx,
|
||||
bson.M{"instance_id": instanceID, "user_id": userID})
|
||||
return err
|
||||
}
|
||||
|
||||
// RollbackInstance deletes an instance that has no users.
|
||||
func RollbackInstance(ctx context.Context, instanceID string) error {
|
||||
return provision.RollbackInstance(ctx, db.ControlDB(), instanceID)
|
||||
}
|
||||
|
||||
// OwnerUserID returns the control-plane user_id of an instance's owner, so a
|
||||
// caller can unwind a partial provision without re-deriving it.
|
||||
func OwnerUserID(ctx context.Context, instanceID string) (string, error) {
|
||||
var u sharedmodels.User
|
||||
err := db.Control("users").FindOne(ctx, bson.M{
|
||||
"instance_id": instanceID,
|
||||
"role": sharedmodels.RoleOwner,
|
||||
}).Decode(&u)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return u.UserID, nil
|
||||
}
|
||||
|
||||
// GrantUser projects an HQ person into a control-plane instance.
|
||||
//
|
||||
// The password hash is copied from customer_users rather than re-derived: HQ
|
||||
// owns the password, and a grant that asked for a password again would create
|
||||
// a second credential for one person.
|
||||
//
|
||||
// The row is written with auth_source "hq" and hq_user_id set, which is what
|
||||
// makes the control plane refuse to edit it locally and what lets a password
|
||||
// change find it later.
|
||||
func GrantUser(ctx context.Context, instanceID, email, passwordHash, role, hqUserID string) (*sharedmodels.User, error) {
|
||||
u, err := provision.CreateUserWithHash(ctx, db.ControlDB(), instanceID,
|
||||
email, passwordHash, role, sharedmodels.AuthHQ)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := db.Control("users").UpdateOne(ctx,
|
||||
bson.M{"user_id": u.UserID},
|
||||
bson.M{"$set": bson.M{"hq_user_id": hqUserID}}); err != nil {
|
||||
// Unwind: a projected row with no hq_user_id is invisible to revoke and
|
||||
// to password propagation, which is worse than no row at all.
|
||||
_, _ = db.Control("users").DeleteOne(ctx, bson.M{"user_id": u.UserID})
|
||||
return nil, fmt.Errorf("set hq_user_id: %w", err)
|
||||
}
|
||||
u.HQUserID = hqUserID
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// RevokeUser deletes the projected row for one person in one instance.
|
||||
//
|
||||
// Deleting rather than disabling is deliberate: the control plane has no
|
||||
// concept of a disabled user, and a row that still exists is a row that can
|
||||
// still sign in.
|
||||
func RevokeUser(ctx context.Context, instanceID, hqUserID string) error {
|
||||
_, err := db.Control("users").DeleteOne(ctx, bson.M{
|
||||
"instance_id": instanceID,
|
||||
"hq_user_id": hqUserID,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// SetMemberRole changes a projected user's role inside one instance.
|
||||
func SetMemberRole(ctx context.Context, instanceID, hqUserID, role string) error {
|
||||
if !sharedmodels.ValidRole(role) {
|
||||
return fmt.Errorf("invalid role %q", role)
|
||||
}
|
||||
res, err := db.Control("users").UpdateOne(ctx,
|
||||
bson.M{"instance_id": instanceID, "hq_user_id": hqUserID},
|
||||
bson.M{"$set": bson.M{"role": role}})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if res.MatchedCount == 0 {
|
||||
return fmt.Errorf("no projected user in instance %s", instanceID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CountOtherOwners counts owners of an instance other than one HQ person.
|
||||
//
|
||||
// It counts CONTROL-PLANE owners, so an owner created locally inside the
|
||||
// instance counts too. That matters: refusing to revoke the last HQ owner of
|
||||
// an instance that has three local owners would be a refusal with no cause.
|
||||
//
|
||||
// $ne matches documents where the field is absent, which is exactly how a
|
||||
// locally-created owner is stored.
|
||||
func CountOtherOwners(ctx context.Context, instanceID, exceptHQUserID string) (int64, error) {
|
||||
return db.Control("users").CountDocuments(ctx, bson.M{
|
||||
"instance_id": instanceID,
|
||||
"role": sharedmodels.RoleOwner,
|
||||
"hq_user_id": bson.M{"$ne": exceptHQUserID},
|
||||
})
|
||||
}
|
||||
|
||||
// SetPasswordHash writes one hash to every row projected from one HQ person,
|
||||
// across every instance, and reports how many it changed.
|
||||
func SetPasswordHash(ctx context.Context, hqUserID, hash string) (int64, error) {
|
||||
res, err := db.Control("users").UpdateMany(ctx,
|
||||
bson.M{"hq_user_id": hqUserID},
|
||||
bson.M{"$set": bson.M{"password_hash": hash}})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.ModifiedCount, nil
|
||||
}
|
||||
|
||||
// ProjectedUsers returns every control-plane row projected from one HQ person.
|
||||
// hqsync uses it to compare hashes.
|
||||
func ProjectedUsers(ctx context.Context, hqUserID string) ([]sharedmodels.User, error) {
|
||||
cur, err := db.Control("users").Find(ctx, bson.M{"hq_user_id": hqUserID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var users []sharedmodels.User
|
||||
if err := cur.All(ctx, &users); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
// Package config parses and validates admin's environment.
|
||||
//
|
||||
// Everything required is checked at boot and the process refuses to start
|
||||
// without it. A licensing service that cannot sign is worse than one that is
|
||||
// down, because it looks healthy.
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
AdminMongoURI string
|
||||
AdminDBName string
|
||||
ControlMongoURI string
|
||||
ControlDBName string
|
||||
RedisAddr string
|
||||
RedisUsername string
|
||||
RedisPassword string
|
||||
SigningKey string
|
||||
PublicURL string
|
||||
AppLoginURL string
|
||||
AllowedOrigins []string
|
||||
TrustProxy bool
|
||||
Addr string
|
||||
ReapAfter time.Duration
|
||||
|
||||
PaddleEnv string // "sandbox" or "production"
|
||||
PaddleAPIKey string
|
||||
PaddleWebhookSecret string
|
||||
|
||||
SMTPHost string
|
||||
SMTPPort string
|
||||
SMTPFrom string
|
||||
SMTPUsername string
|
||||
SMTPPassword string
|
||||
}
|
||||
|
||||
// dbNameFromURI reads the database from a Mongo URI path.
|
||||
//
|
||||
// Both URIs must name their database inline rather than through a separate
|
||||
// variable. Admin talks to two databases; a bare MONGO_DB would be ambiguous
|
||||
// about which, and guessing wrong means writing licence fields into the wrong
|
||||
// place.
|
||||
func dbNameFromURI(raw, which string) (string, error) {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s is not a valid URI: %w", which, err)
|
||||
}
|
||||
name := strings.TrimPrefix(u.Path, "/")
|
||||
if name == "" {
|
||||
return "", fmt.Errorf("%s must name a database in its path, e.g. mongodb://host:27017/vantage_admin", which)
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
c := Config{
|
||||
AdminMongoURI: os.Getenv("ADMIN_MONGO_URI"),
|
||||
ControlMongoURI: os.Getenv("CONTROL_MONGO_URI"),
|
||||
RedisAddr: os.Getenv("REDIS_ADDR"),
|
||||
// Optional: an unauthenticated Redis needs neither. Redis 6+ ACL auth
|
||||
// takes both; a legacy `requirepass` instance takes the password alone
|
||||
// and must leave the username empty.
|
||||
RedisUsername: os.Getenv("REDIS_USERNAME"),
|
||||
RedisPassword: os.Getenv("REDIS_PASSWORD"),
|
||||
SigningKey: os.Getenv("LICENSE_SIGNING_KEY"),
|
||||
PublicURL: strings.TrimSuffix(os.Getenv("PUBLIC_URL"), "/"),
|
||||
AppLoginURL: os.Getenv("APP_LOGIN_URL"),
|
||||
TrustProxy: strings.EqualFold(os.Getenv("TRUST_PROXY"), "true"),
|
||||
Addr: ":" + envOr("PORT", "8083"),
|
||||
|
||||
SMTPHost: os.Getenv("SMTP_HOST"),
|
||||
SMTPPort: envOr("SMTP_PORT", "587"),
|
||||
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
|
||||
for name, v := range map[string]string{
|
||||
"ADMIN_MONGO_URI": c.AdminMongoURI,
|
||||
"CONTROL_MONGO_URI": c.ControlMongoURI,
|
||||
"REDIS_ADDR": c.RedisAddr,
|
||||
"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)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return Config{}, fmt.Errorf("missing required environment: %s", strings.Join(missing, ", "))
|
||||
}
|
||||
|
||||
var err error
|
||||
if c.AdminDBName, err = dbNameFromURI(c.AdminMongoURI, "ADMIN_MONGO_URI"); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if c.ControlDBName, err = dbNameFromURI(c.ControlMongoURI, "CONTROL_MONGO_URI"); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if c.AdminMongoURI == c.ControlMongoURI {
|
||||
return Config{}, fmt.Errorf("ADMIN_MONGO_URI and CONTROL_MONGO_URI must not be the same database")
|
||||
}
|
||||
|
||||
for _, o := range strings.Split(os.Getenv("ADMIN_ORIGIN"), ",") {
|
||||
if o = strings.TrimSpace(o); o != "" {
|
||||
c.AllowedOrigins = append(c.AllowedOrigins, o)
|
||||
}
|
||||
}
|
||||
|
||||
// Mirrors the control plane's FREE_INSTANCE_REAP_AFTER so notice emails can
|
||||
// name the real deletion date. An unparseable value is refused rather than
|
||||
// silently treated as "off": a typo here would quietly stop every deletion
|
||||
// warning while the control plane still deletes.
|
||||
if v := os.Getenv("FREE_INSTANCE_REAP_AFTER"); v != "" {
|
||||
d, err := time.ParseDuration(v)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("FREE_INSTANCE_REAP_AFTER %q: %w", v, err)
|
||||
}
|
||||
c.ReapAfter = d
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func envOr(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// Package db holds admin's two MongoDB connections.
|
||||
//
|
||||
// Admin() is its own database and it owns every collection there. Control() is
|
||||
// the control plane's database. Admin's access to it is narrow and lives in
|
||||
// exactly two packages: inject writes three licence fields on `instances`, and
|
||||
// cloudprov creates and rolls back `instances` and `users` when a customer
|
||||
// provisions a cloud instance. Nothing else may write there, and a third write
|
||||
// path is a design change rather than a refactor.
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
var (
|
||||
adminDB *mongo.Database
|
||||
controlDB *mongo.Database
|
||||
)
|
||||
|
||||
func Connect(ctx context.Context, cfg config.Config) error {
|
||||
ac, err := mongo.Connect(options.Client().ApplyURI(cfg.AdminMongoURI))
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect admin mongo: %w", err)
|
||||
}
|
||||
if err := ac.Ping(ctx, nil); err != nil {
|
||||
return fmt.Errorf("ping admin mongo: %w", err)
|
||||
}
|
||||
adminDB = ac.Database(cfg.AdminDBName)
|
||||
|
||||
cc, err := mongo.Connect(options.Client().ApplyURI(cfg.ControlMongoURI))
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect control mongo: %w", err)
|
||||
}
|
||||
if err := cc.Ping(ctx, nil); err != nil {
|
||||
return fmt.Errorf("ping control mongo: %w", err)
|
||||
}
|
||||
controlDB = cc.Database(cfg.ControlDBName)
|
||||
|
||||
// The control plane must already be deployed and migrated. Without the
|
||||
// instances collection, injection would silently create it and write
|
||||
// licence fields into a collection nothing reads.
|
||||
names, err := controlDB.ListCollectionNames(ctx, map[string]any{"name": "instances"})
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect control database: %w", err)
|
||||
}
|
||||
if len(names) == 0 {
|
||||
return fmt.Errorf("control database %q has no instances collection; deploy and migrate the control plane first", cfg.ControlDBName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Admin(name string) *mongo.Collection { return adminDB.Collection(name) }
|
||||
func Control(name string) *mongo.Collection { return controlDB.Collection(name) }
|
||||
|
||||
// ControlDB exposes the control-plane database itself, because shared/provision
|
||||
// takes a database rather than a collection.
|
||||
//
|
||||
// It is used by cloudprov and nothing else. Reach for Control(name) unless you
|
||||
// are calling into shared/provision.
|
||||
func ControlDB() *mongo.Database { return controlDB }
|
||||
|
||||
func Ctx() (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), 10*time.Second)
|
||||
}
|
||||
|
||||
// EnsureIndexes creates admin's unique indexes.
|
||||
//
|
||||
// These are a correctness property, not an optimisation. In particular
|
||||
// admin_instances.instance_id unique is what stops the same self-hosted UUID
|
||||
// being linked to two accounts — without it, two customers could both claim one
|
||||
// instance and both be issued licences for it.
|
||||
func EnsureIndexes(ctx context.Context) error {
|
||||
unique := []struct {
|
||||
coll string
|
||||
field string
|
||||
}{
|
||||
{"accounts", "account_id"},
|
||||
{"admin_instances", "instance_id"},
|
||||
{"licenses", "license_id"},
|
||||
{"paddle_events", "event_id"},
|
||||
{"staff_users", "email"},
|
||||
{"customer_users", "email"},
|
||||
}
|
||||
for _, u := range unique {
|
||||
if _, err := Admin(u.coll).Indexes().CreateOne(ctx, mongo.IndexModel{
|
||||
Keys: bson.D{{Key: u.field, Value: 1}},
|
||||
Options: options.Index().SetUnique(true).SetName(u.field + "_unique"),
|
||||
}); err != nil {
|
||||
return fmt.Errorf("index %s.%s: %w", u.coll, u.field, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Sparse: a subscription exists before Paddle assigns an ID, so empty must
|
||||
// not collide with empty.
|
||||
if _, err := Admin("subscriptions").Indexes().CreateOne(ctx, mongo.IndexModel{
|
||||
Keys: bson.D{{Key: "paddle_subscription_id", Value: 1}},
|
||||
Options: options.Index().SetUnique(true).SetSparse(true).SetName("paddle_subscription_id_unique"),
|
||||
}); err != nil {
|
||||
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
|
||||
}{
|
||||
{"licenses", bson.D{{Key: "instance_id", Value: 1}, {Key: "issued_at", Value: -1}}},
|
||||
{"admin_instances", bson.D{{Key: "account_id", Value: 1}}},
|
||||
{"admin_audit", bson.D{{Key: "created_at", Value: -1}}},
|
||||
} {
|
||||
if _, err := Admin(idx.coll).Indexes().CreateOne(ctx, mongo.IndexModel{Keys: idx.keys}); err != nil {
|
||||
return fmt.Errorf("index %s: %w", idx.coll, err)
|
||||
}
|
||||
}
|
||||
|
||||
// One person holds at most one user in one instance. This is the property
|
||||
// that makes a grant idempotent-by-refusal rather than silently doubling a
|
||||
// projection, and it mirrors users' own (instance_id, email) uniqueness.
|
||||
if _, err := Admin("instance_members").Indexes().CreateOne(ctx, mongo.IndexModel{
|
||||
Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "customer_user_id", Value: 1}},
|
||||
Options: options.Index().SetUnique(true).
|
||||
SetName("instance_customer_user_unique"),
|
||||
}); err != nil {
|
||||
return fmt.Errorf("index instance_members.(instance_id,customer_user_id): %w", err)
|
||||
}
|
||||
for _, keys := range []bson.D{
|
||||
{{Key: "account_id", Value: 1}},
|
||||
{{Key: "customer_user_id", Value: 1}},
|
||||
} {
|
||||
if _, err := Admin("instance_members").Indexes().CreateOne(ctx,
|
||||
mongo.IndexModel{Keys: keys}); err != nil {
|
||||
return fmt.Errorf("index instance_members: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// Package hqsync keeps projected control-plane users consistent with the HQ
|
||||
// people they were projected from.
|
||||
//
|
||||
// It is separate from inject on purpose. inject writes exactly three licence
|
||||
// fields on `instances` and that narrowness is the reason admin's reach into
|
||||
// the control plane is reviewable at all; a password repair pass bolted onto it
|
||||
// would quietly turn it into "the package that writes whatever admin wants".
|
||||
// This one goes through cloudprov, which is the sanctioned user write path.
|
||||
package hqsync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// Interval matches inject's reconciler. Fifteen minutes is the worst-case
|
||||
// staleness a password change can suffer, which the spec accepts as
|
||||
// recoverable.
|
||||
const Interval = 15 * time.Minute
|
||||
|
||||
// Reconcile compares every projected user's stored hash against the HQ hash it
|
||||
// came from, and repairs mismatches.
|
||||
//
|
||||
// The comparison is on the hash string, not the password: two bcrypt hashes of
|
||||
// one password differ by salt, so this repairs by COPYING HQ's hash rather than
|
||||
// re-hashing. That is also why propagation copies rather than re-derives.
|
||||
func Reconcile(ctx context.Context) (checked, repaired int, err error) {
|
||||
cur, err := db.Admin("customer_users").Find(ctx,
|
||||
bson.M{"password_hash": bson.M{"$nin": bson.A{nil, ""}}})
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
var people []models.CustomerUser
|
||||
if err := cur.All(ctx, &people); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
for _, p := range people {
|
||||
projected, err := cloudprov.ProjectedUsers(ctx, p.UserID)
|
||||
if err != nil {
|
||||
log.Printf("hqsync: read projections of %s: %v", p.Email, err)
|
||||
continue
|
||||
}
|
||||
stale := false
|
||||
for _, u := range projected {
|
||||
checked++
|
||||
if u.PasswordHash != p.PasswordHash {
|
||||
stale = true
|
||||
}
|
||||
}
|
||||
if !stale {
|
||||
// Clear a stale failure flag: the instances agree, whatever the
|
||||
// flag says. Nothing reads the flag to decide what to repair.
|
||||
if p.HQSyncFailedAt != nil {
|
||||
_, _ = db.Admin("customer_users").UpdateOne(ctx,
|
||||
bson.M{"user_id": p.UserID},
|
||||
bson.M{"$unset": bson.M{"hq_sync_failed_at": ""}})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
n, err := cloudprov.SetPasswordHash(ctx, p.UserID, p.PasswordHash)
|
||||
if err != nil {
|
||||
log.Printf("hqsync: repair %s: %v", p.Email, err)
|
||||
continue
|
||||
}
|
||||
repaired += int(n)
|
||||
log.Printf("hqsync: repaired %d projected user(s) for %s", n, p.Email)
|
||||
_, _ = db.Admin("customer_users").UpdateOne(ctx,
|
||||
bson.M{"user_id": p.UserID},
|
||||
bson.M{"$unset": bson.M{"hq_sync_failed_at": ""}})
|
||||
}
|
||||
return checked, repaired, nil
|
||||
}
|
||||
|
||||
// Start runs once at boot, then on a ticker until ctx is cancelled.
|
||||
//
|
||||
// The boot pass is for the same reason inject's is: the likeliest moment for a
|
||||
// half-applied write is a deploy or a crash, and waiting a full interval to
|
||||
// notice means a customer's new password does not work somewhere for fifteen
|
||||
// minutes after we already know how to fix it.
|
||||
func Start(ctx context.Context) {
|
||||
go func() {
|
||||
runOnce(ctx)
|
||||
t := time.NewTicker(Interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
runOnce(ctx)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func runOnce(ctx context.Context) {
|
||||
runCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
checked, repaired, err := Reconcile(runCtx)
|
||||
if err != nil {
|
||||
log.Printf("hqsync: %v", err)
|
||||
return
|
||||
}
|
||||
if repaired > 0 {
|
||||
log.Printf("hqsync: checked %d projected user(s), repaired %d", checked, repaired)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
// Package inject writes licences onto control-plane instance documents.
|
||||
//
|
||||
// This is admin's ONLY write path into the control plane, and it touches exactly
|
||||
// three fields on one collection. If this package ever grows a second write
|
||||
// target, that is a design change and not a refactor.
|
||||
package inject
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// ReconcileInterval is how often every cloud instance is compared against what
|
||||
// admin believes it should hold.
|
||||
//
|
||||
// This job, not the issuance path, is what guarantees eventual consistency.
|
||||
// Injection at issue time is best-effort; this is the backstop.
|
||||
const ReconcileInterval = 15 * time.Minute
|
||||
|
||||
// Cloud writes the licence onto the control-plane instance document.
|
||||
//
|
||||
// Idempotent and safe to re-run: it is a single UpdateOne of three fields with
|
||||
// no read-modify-write. Retries three times with backoff.
|
||||
//
|
||||
// The control plane caches licence state for 60 seconds, so this takes effect
|
||||
// within a minute with no restart.
|
||||
func Cloud(ctx context.Context, lic *models.License) error {
|
||||
set := bson.M{"$set": bson.M{
|
||||
"license_blob": lic.Blob,
|
||||
"license_tier": lic.Tier,
|
||||
"license_expiry": lic.ExpiresAt,
|
||||
}}
|
||||
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= 3; attempt++ {
|
||||
res, err := db.Control("instances").UpdateOne(ctx,
|
||||
bson.M{"instance_id": lic.InstanceID}, set)
|
||||
if err == nil {
|
||||
if res.MatchedCount == 0 {
|
||||
return fmt.Errorf("no control-plane instance %s", lic.InstanceID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
lastErr = err
|
||||
time.Sleep(time.Duration(attempt) * 2 * time.Second)
|
||||
}
|
||||
return fmt.Errorf("inject after 3 attempts: %w", lastErr)
|
||||
}
|
||||
|
||||
// Deliver injects and records the outcome without ever failing the caller.
|
||||
//
|
||||
// A licence that is recorded but not injected is recoverable — the reconciler
|
||||
// will fix it within 15 minutes, and staff can see it on the health endpoint.
|
||||
// Failing the purchase because one write failed would be worse.
|
||||
func Deliver(ctx context.Context, lic *models.License) {
|
||||
if err := Cloud(ctx, lic); err != nil {
|
||||
log.Printf("INJECTION FAILED instance=%s licence=%s: %v", lic.InstanceID, lic.LicenseID, err)
|
||||
now := time.Now().UTC()
|
||||
_, _ = db.Admin("admin_instances").UpdateOne(ctx,
|
||||
bson.M{"instance_id": lic.InstanceID},
|
||||
bson.M{"$set": bson.M{"inject_failed_at": now}})
|
||||
return
|
||||
}
|
||||
_, _ = db.Admin("admin_instances").UpdateOne(ctx,
|
||||
bson.M{"instance_id": lic.InstanceID},
|
||||
bson.M{"$unset": bson.M{"inject_failed_at": ""}})
|
||||
}
|
||||
|
||||
// Reconcile compares every active cloud instance's current licence against the
|
||||
// blob actually stored in the control plane, and re-injects on mismatch.
|
||||
func Reconcile(ctx context.Context) (checked, repaired int, err error) {
|
||||
cur, err := db.Admin("admin_instances").Find(ctx, bson.M{
|
||||
"deployment": license.DeploymentCloud,
|
||||
"status": models.StatusActive,
|
||||
"current_license": bson.M{"$ne": ""},
|
||||
})
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
var instances []models.Instance
|
||||
if err := cur.All(ctx, &instances); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
for _, inst := range instances {
|
||||
checked++
|
||||
|
||||
var lic models.License
|
||||
if err := db.Admin("licenses").FindOne(ctx,
|
||||
bson.M{"license_id": inst.CurrentLicense}).Decode(&lic); err != nil {
|
||||
log.Printf("reconcile: instance %s references unknown licence %s", inst.InstanceID, inst.CurrentLicense)
|
||||
continue
|
||||
}
|
||||
|
||||
var remote sharedmodels.Instance
|
||||
if err := db.Control("instances").FindOne(ctx,
|
||||
bson.M{"instance_id": inst.InstanceID}).Decode(&remote); err != nil {
|
||||
// The control plane's reaper deletes lapsed Free instances. Record
|
||||
// that here rather than re-logging it every fifteen minutes forever,
|
||||
// and so the lifecycle sweep stops emailing about it.
|
||||
if errors.Is(err, mongo.ErrNoDocuments) {
|
||||
if _, uErr := db.Admin("admin_instances").UpdateOne(ctx,
|
||||
bson.M{"instance_id": inst.InstanceID},
|
||||
bson.M{"$set": bson.M{"status": models.StatusDeleted}}); uErr != nil {
|
||||
log.Printf("reconcile: mark %s deleted: %v", inst.InstanceID, uErr)
|
||||
} else {
|
||||
log.Printf("reconcile: instance %s is gone from the control plane; marked deleted", inst.InstanceID)
|
||||
}
|
||||
continue
|
||||
}
|
||||
log.Printf("reconcile: no control-plane instance %s: %v", inst.InstanceID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if remote.LicenseBlob == lic.Blob {
|
||||
continue
|
||||
}
|
||||
|
||||
log.Printf("reconcile: repairing instance %s (licence %s)", inst.InstanceID, lic.LicenseID)
|
||||
if err := Cloud(ctx, &lic); err != nil {
|
||||
log.Printf("reconcile: repair failed for %s: %v", inst.InstanceID, err)
|
||||
continue
|
||||
}
|
||||
repaired++
|
||||
_, _ = db.Admin("admin_instances").UpdateOne(ctx,
|
||||
bson.M{"instance_id": inst.InstanceID},
|
||||
bson.M{"$unset": bson.M{"inject_failed_at": ""}})
|
||||
}
|
||||
return checked, repaired, nil
|
||||
}
|
||||
|
||||
// StartReconciler reconciles once at boot, then on a ticker until ctx is
|
||||
// cancelled.
|
||||
//
|
||||
// The pass at boot matters: injection failures are most likely around a deploy
|
||||
// or a crash, and waiting a full interval to notice would leave a paying
|
||||
// customer read-only for that long. It also means a restart is a supported way
|
||||
// to force reconciliation.
|
||||
func StartReconciler(ctx context.Context) {
|
||||
go func() {
|
||||
runOnce(ctx)
|
||||
|
||||
t := time.NewTicker(ReconcileInterval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
runOnce(ctx)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func runOnce(ctx context.Context) {
|
||||
runCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
checked, repaired, err := Reconcile(runCtx)
|
||||
if err != nil {
|
||||
log.Printf("reconcile: %v", err)
|
||||
return
|
||||
}
|
||||
if repaired > 0 {
|
||||
log.Printf("reconcile: checked %d, repaired %d", checked, repaired)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
// Package licensing issues licences. It is the only place that signs.
|
||||
package licensing
|
||||
|
||||
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"
|
||||
"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 of that deployment type")
|
||||
ErrUnknownInstance = errors.New("instance not found")
|
||||
)
|
||||
|
||||
type IssueInput struct {
|
||||
InstanceID string
|
||||
Tier string
|
||||
Term string // "monthly" or "annual"; ignored when ExpiresAt is set
|
||||
ExpiresAt time.Time // explicit expiry, used by relink to preserve the remaining term
|
||||
Reason string
|
||||
IssuedBy string // staff email, "system", or "paddle:<event id>"
|
||||
}
|
||||
|
||||
// signingKey is set once at boot from LICENSE_SIGNING_KEY.
|
||||
var signingKey string
|
||||
|
||||
func SetSigningKey(k string) { signingKey = k }
|
||||
|
||||
// Issue signs a licence, records it, supersedes its predecessor and updates the
|
||||
// instance.
|
||||
//
|
||||
// It does NOT deliver. Recording and delivery are deliberately separate and
|
||||
// ordered: a licence recorded but not delivered is recoverable, because the
|
||||
// customer can download it. A licence delivered but not recorded is a support
|
||||
// mystery with no paper trail. Callers deliver after this returns.
|
||||
func Issue(ctx context.Context, in IssueInput) (*models.License, error) {
|
||||
if signingKey == "" {
|
||||
return nil, errors.New("no signing key configured")
|
||||
}
|
||||
|
||||
var inst models.Instance
|
||||
if err := db.Admin("admin_instances").FindOne(ctx,
|
||||
bson.M{"instance_id": in.InstanceID}).Decode(&inst); err != nil {
|
||||
return nil, ErrUnknownInstance
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// This single comparison is what makes Free cloud-only. Free's plan is
|
||||
// deployment "cloud", so it can never be issued against a self-hosted
|
||||
// instance, and verification on the instance would reject it anyway.
|
||||
if plan.Deployment != inst.Deployment {
|
||||
return nil, fmt.Errorf("%w: %s is %s only", ErrDeploymentMismatch, plan.Name, plan.Deployment)
|
||||
}
|
||||
|
||||
if plan.Tier == license.TierFree {
|
||||
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() {
|
||||
switch in.Term {
|
||||
case "monthly":
|
||||
expires = now.AddDate(0, 1, 0).Add(models.GracePeriod)
|
||||
case "annual", "":
|
||||
expires = now.AddDate(1, 0, 0).Add(models.GracePeriod)
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown term %q", in.Term)
|
||||
}
|
||||
}
|
||||
|
||||
payload := license.License{
|
||||
ID: uuid.NewString(),
|
||||
InstanceID: inst.InstanceID,
|
||||
AccountID: inst.AccountID,
|
||||
InstanceName: inst.Name,
|
||||
Tier: plan.Tier,
|
||||
Deployment: plan.Deployment,
|
||||
IssuedAt: now,
|
||||
ExpiresAt: expires,
|
||||
// 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)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sign: %w", err)
|
||||
}
|
||||
|
||||
rec := models.License{
|
||||
LicenseID: payload.ID,
|
||||
InstanceID: inst.InstanceID,
|
||||
AccountID: inst.AccountID,
|
||||
Tier: plan.Tier,
|
||||
Deployment: plan.Deployment,
|
||||
Limits: limits,
|
||||
Features: models.Features(features).OrEmpty(),
|
||||
IssuedAt: now,
|
||||
ExpiresAt: expires,
|
||||
Blob: blob,
|
||||
IssuedBy: in.IssuedBy,
|
||||
Reason: in.Reason,
|
||||
}
|
||||
if _, err := db.Admin("licenses").InsertOne(ctx, rec); err != nil {
|
||||
return nil, fmt.Errorf("record licence: %w", err)
|
||||
}
|
||||
|
||||
// Supersede rather than delete. The history is the support tool.
|
||||
if inst.CurrentLicense != "" {
|
||||
if _, err := db.Admin("licenses").UpdateOne(ctx,
|
||||
bson.M{"license_id": inst.CurrentLicense},
|
||||
bson.M{"$set": bson.M{"superseded_by": rec.LicenseID}}); err != nil {
|
||||
return nil, fmt.Errorf("supersede previous licence: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
set := bson.M{
|
||||
"current_license": rec.LicenseID,
|
||||
"tier": plan.Tier,
|
||||
"status": models.StatusActive,
|
||||
}
|
||||
if in.Reason == models.ReasonRenewal {
|
||||
set["relink_count"] = 0 // the cap is per term
|
||||
}
|
||||
if _, err := db.Admin("admin_instances").UpdateOne(ctx,
|
||||
bson.M{"instance_id": inst.InstanceID}, bson.M{"$set": set}); err != nil {
|
||||
return nil, fmt.Errorf("update instance: %w", err)
|
||||
}
|
||||
|
||||
audit.Write(ctx, models.AuditEntry{
|
||||
Actor: in.IssuedBy,
|
||||
Action: "license.issued",
|
||||
AccountID: inst.AccountID,
|
||||
Target: inst.InstanceID,
|
||||
Detail: fmt.Sprintf("tier=%s reason=%s expires=%s licence=%s",
|
||||
plan.Tier, in.Reason, expires.Format(time.RFC3339), rec.LicenseID),
|
||||
})
|
||||
|
||||
return &rec, nil
|
||||
}
|
||||
|
||||
// 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, 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},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 0 {
|
||||
return ErrFreeLimit
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package licensing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"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/shared/license"
|
||||
"github.com/google/uuid"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrBadUUID = errors.New("that does not look like an instance ID")
|
||||
ErrAlreadyLinked = errors.New("that instance ID is already linked to an account")
|
||||
ErrRelinkLimit = errors.New("relink limit reached for this term; contact support")
|
||||
)
|
||||
|
||||
// LinkInstance attaches a self-hosted instance UUID to an account.
|
||||
//
|
||||
// The duplicate error deliberately does not say WHICH account holds it. It is a
|
||||
// small enumeration surface, but there is no reason to leave it open.
|
||||
func LinkInstance(ctx context.Context, accountID, instanceID, name string) (*models.Instance, error) {
|
||||
if _, err := uuid.Parse(instanceID); err != nil {
|
||||
return nil, ErrBadUUID
|
||||
}
|
||||
|
||||
// A self-hosted UUID must not collide with a cloud instance either.
|
||||
if n, err := db.Control("instances").CountDocuments(ctx, bson.M{"instance_id": instanceID}); err == nil && n > 0 {
|
||||
return nil, ErrAlreadyLinked
|
||||
}
|
||||
|
||||
inst := models.Instance{
|
||||
InstanceID: instanceID,
|
||||
AccountID: accountID,
|
||||
Name: name,
|
||||
Deployment: license.DeploymentSelfHosted,
|
||||
Status: models.StatusActive,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
if _, err := db.Admin("admin_instances").InsertOne(ctx, inst); err != nil {
|
||||
if mongo.IsDuplicateKeyError(err) {
|
||||
// The unique index is what actually prevents two accounts owning
|
||||
// one instance. The check above is a nicety; this is the guarantee.
|
||||
return nil, ErrAlreadyLinked
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
audit.Write(ctx, models.AuditEntry{
|
||||
Actor: accountID, Action: "instance.linked", AccountID: accountID, Target: instanceID})
|
||||
return &inst, 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
|
||||
// a way to extend a subscription.
|
||||
//
|
||||
// The old licence is not revoked, because offline verification has no
|
||||
// revocation. It simply no longer matches any UUID the customer controls, and
|
||||
// its binding stops it working on another machine anyway.
|
||||
func Relink(ctx context.Context, accountID, oldID, newID string, staff bool) (*models.License, error) {
|
||||
if _, err := uuid.Parse(newID); err != nil {
|
||||
return nil, ErrBadUUID
|
||||
}
|
||||
|
||||
var inst models.Instance
|
||||
if err := db.Admin("admin_instances").FindOne(ctx,
|
||||
bson.M{"instance_id": oldID, "account_id": accountID}).Decode(&inst); err != nil {
|
||||
return nil, ErrUnknownInstance
|
||||
}
|
||||
|
||||
// The cap is a signal, not a defence. Its job is to put a human in front of
|
||||
// the fourth attempt, so staff bypass it.
|
||||
if !staff && inst.RelinkCount >= models.MaxRelinksPerTerm {
|
||||
return nil, ErrRelinkLimit
|
||||
}
|
||||
|
||||
if n, err := db.Admin("admin_instances").CountDocuments(ctx, bson.M{"instance_id": newID}); err == nil && n > 0 {
|
||||
return nil, ErrAlreadyLinked
|
||||
}
|
||||
|
||||
// Preserve the remaining term from the current licence.
|
||||
remaining := time.Now().UTC().Add(models.GracePeriod)
|
||||
var current models.License
|
||||
if err := db.Admin("licenses").FindOne(ctx,
|
||||
bson.M{"license_id": inst.CurrentLicense}).Decode(¤t); err == nil {
|
||||
remaining = current.ExpiresAt
|
||||
}
|
||||
|
||||
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 {
|
||||
if mongo.IsDuplicateKeyError(err) {
|
||||
return nil, ErrAlreadyLinked
|
||||
}
|
||||
return nil, fmt.Errorf("relink: %w", err)
|
||||
}
|
||||
|
||||
actor := accountID
|
||||
if staff {
|
||||
actor = "staff"
|
||||
}
|
||||
audit.Write(ctx, models.AuditEntry{
|
||||
Actor: actor, Action: "instance.relinked", AccountID: accountID,
|
||||
Target: newID, Detail: "was " + oldID})
|
||||
|
||||
return Issue(ctx, IssueInput{
|
||||
InstanceID: newID,
|
||||
Tier: inst.Tier,
|
||||
ExpiresAt: remaining,
|
||||
Reason: models.ReasonRelink,
|
||||
IssuedBy: actor,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
// Package lifecycle marks lapsed Free instances and sends the renewal notices.
|
||||
//
|
||||
// It sends; it never deletes. Deletion belongs to the control plane, which is
|
||||
// the only service that knows what an instance is made of. The two are kept
|
||||
// apart on purpose: a bug here sends a wrong email, a bug there loses data.
|
||||
package lifecycle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// Interval is how often the sweep runs. Hourly is far finer than the daily
|
||||
// granularity of the notices, which means a notice goes out within an hour of
|
||||
// becoming due rather than up to a day late.
|
||||
const Interval = time.Hour
|
||||
|
||||
// Notice keys, recorded on the instance so a restart cannot re-send one.
|
||||
const (
|
||||
noticeExpiring = "expiring"
|
||||
noticeExpired = "expired"
|
||||
noticeDelete7 = "delete_7"
|
||||
noticeDelete1 = "delete_1"
|
||||
)
|
||||
|
||||
// portalURL is the customer portal address used in notice emails.
|
||||
var portalURL string
|
||||
|
||||
// SetPortalURL is called once at boot.
|
||||
func SetPortalURL(v string) { portalURL = v }
|
||||
|
||||
// reapAfter mirrors the control plane's FREE_INSTANCE_REAP_AFTER so the emails
|
||||
// can name the real deletion date. Zero means the reaper is off, and the
|
||||
// deletion notices are then suppressed — promising a deletion that will never
|
||||
// happen would be a lie, and a scarier one than saying nothing.
|
||||
var reapAfter time.Duration
|
||||
|
||||
// Run performs one sweep: mark lapsed instances, then send whatever notices are
|
||||
// due. Errors on one instance never stop the others.
|
||||
func Run(ctx context.Context) error {
|
||||
now := time.Now().UTC()
|
||||
|
||||
cur, err := db.Admin("admin_instances").Find(ctx, bson.M{
|
||||
"deployment": license.DeploymentCloud,
|
||||
"tier": license.TierFree,
|
||||
"status": bson.M{"$in": []string{models.StatusActive, models.StatusLapsed}},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var instances []models.Instance
|
||||
if err := cur.All(ctx, &instances); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, inst := range instances {
|
||||
var lic models.License
|
||||
if err := db.Admin("licenses").FindOne(ctx,
|
||||
bson.M{"license_id": inst.CurrentLicense}).Decode(&lic); err != nil {
|
||||
continue // no licence yet; nothing to expire
|
||||
}
|
||||
|
||||
// Flip active -> lapsed once the licence is past its expiry.
|
||||
if now.After(lic.ExpiresAt) && inst.Status == models.StatusActive {
|
||||
if _, err := db.Admin("admin_instances").UpdateOne(ctx,
|
||||
bson.M{"instance_id": inst.InstanceID},
|
||||
bson.M{"$set": bson.M{"status": models.StatusLapsed}}); err != nil {
|
||||
log.Printf("lifecycle: mark %s lapsed: %v", inst.InstanceID, err)
|
||||
}
|
||||
}
|
||||
|
||||
due := dueNotice(now, lic.ExpiresAt, inst.NoticesSent)
|
||||
if due == "" {
|
||||
continue
|
||||
}
|
||||
if err := sendNotice(ctx, inst, lic, due); err != nil {
|
||||
log.Printf("lifecycle: notice %s for %s: %v", due, inst.InstanceID, err)
|
||||
continue
|
||||
}
|
||||
if _, err := db.Admin("admin_instances").UpdateOne(ctx,
|
||||
bson.M{"instance_id": inst.InstanceID},
|
||||
bson.M{"$addToSet": bson.M{"notices_sent": due}}); err != nil {
|
||||
log.Printf("lifecycle: record notice %s for %s: %v", due, inst.InstanceID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// dueNotice returns the most urgent unsent notice, or "".
|
||||
//
|
||||
// Most urgent first, so an instance that was missed for a week — because admin
|
||||
// was down — sends the one that matters now rather than working through a
|
||||
// backlog of stale warnings.
|
||||
func dueNotice(now, expires time.Time, sent []string) string {
|
||||
deleteOn := expires.Add(reapAfter)
|
||||
|
||||
if reapAfter > 0 {
|
||||
if now.After(deleteOn.Add(-24*time.Hour)) && !slices.Contains(sent, noticeDelete1) {
|
||||
return noticeDelete1
|
||||
}
|
||||
if now.After(deleteOn.Add(-7*24*time.Hour)) && !slices.Contains(sent, noticeDelete7) {
|
||||
return noticeDelete7
|
||||
}
|
||||
}
|
||||
if now.After(expires) && !slices.Contains(sent, noticeExpired) {
|
||||
return noticeExpired
|
||||
}
|
||||
if now.After(expires.Add(-models.RenewWindow)) && !slices.Contains(sent, noticeExpiring) {
|
||||
return noticeExpiring
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func sendNotice(ctx context.Context, inst models.Instance, lic models.License, key string) error {
|
||||
if !mail.Enabled() {
|
||||
return nil
|
||||
}
|
||||
var acct models.Account
|
||||
if err := db.Admin("accounts").FindOne(ctx,
|
||||
bson.M{"account_id": inst.AccountID}).Decode(&acct); err != nil {
|
||||
return err
|
||||
}
|
||||
to := acct.BillingEmail
|
||||
deleteOn := lic.ExpiresAt.Add(reapAfter)
|
||||
|
||||
switch key {
|
||||
case noticeExpiring:
|
||||
return mail.Default.SendExpiring(to, inst.Name, portalURL, lic.ExpiresAt)
|
||||
case noticeExpired:
|
||||
return mail.Default.SendExpired(to, inst.Name, portalURL, deleteOn)
|
||||
case noticeDelete7:
|
||||
return mail.Default.SendDeletionWarning(to, inst.Name, portalURL, deleteOn, 7)
|
||||
case noticeDelete1:
|
||||
return mail.Default.SendDeletionWarning(to, inst.Name, portalURL, deleteOn, 1)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start runs the sweep on a ticker until ctx is cancelled.
|
||||
//
|
||||
// reapAfterDur must match the control plane's FREE_INSTANCE_REAP_AFTER. If they
|
||||
// disagree, the emails name a date the reaper does not honour — so they are
|
||||
// documented as a pair in CLAUDE.md and set together in the compose file.
|
||||
func Start(ctx context.Context, reapAfterDur time.Duration) {
|
||||
reapAfter = reapAfterDur
|
||||
go func() {
|
||||
runOnce(ctx)
|
||||
t := time.NewTicker(Interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
runOnce(ctx)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func runOnce(ctx context.Context) {
|
||||
runCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
|
||||
defer cancel()
|
||||
if err := Run(runCtx); err != nil {
|
||||
log.Printf("lifecycle: %v", err)
|
||||
}
|
||||
sweepAwaitingLink(runCtx)
|
||||
}
|
||||
|
||||
// Awaiting-link reminder keys.
|
||||
const (
|
||||
noticeLink24 = "link_24"
|
||||
noticeLink72 = "link_72"
|
||||
)
|
||||
|
||||
// sweepAwaitingLink chases self-hosted instances that were paid for but never
|
||||
// linked: the subscription exists, the instance is still a placeholder. It
|
||||
// emails a reminder at 24h and again at 72h. The staff dashboard already flags
|
||||
// 48h; this is the active chasing on top of that. It never issues or deletes.
|
||||
func sweepAwaitingLink(ctx context.Context) {
|
||||
cur, err := db.Admin("admin_instances").Find(ctx, bson.M{
|
||||
"deployment": license.DeploymentSelfHosted,
|
||||
"placeholder": true,
|
||||
"status": models.StatusAwaitingLink,
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var instances []models.Instance
|
||||
if err := cur.All(ctx, &instances); err != nil {
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
for _, inst := range instances {
|
||||
// Only chase placeholders a customer has actually paid for.
|
||||
n, err := db.Admin("subscriptions").CountDocuments(ctx,
|
||||
bson.M{"instance_id": inst.InstanceID, "status": models.SubActive})
|
||||
if err != nil || n == 0 {
|
||||
continue
|
||||
}
|
||||
if !mail.Enabled() {
|
||||
continue
|
||||
}
|
||||
to := accountEmail(ctx, inst.AccountID)
|
||||
if to == "" {
|
||||
continue
|
||||
}
|
||||
age := now.Sub(inst.CreatedAt)
|
||||
var due string
|
||||
if age > 72*time.Hour && !slices.Contains(inst.NoticesSent, noticeLink72) {
|
||||
due = noticeLink72
|
||||
} else if age > 24*time.Hour && !slices.Contains(inst.NoticesSent, noticeLink24) {
|
||||
due = noticeLink24
|
||||
}
|
||||
if due == "" {
|
||||
continue
|
||||
}
|
||||
if err := mail.Default.SendLinkReminder(to, inst.Name); err != nil {
|
||||
log.Printf("lifecycle: link reminder %s for %s: %v", due, inst.InstanceID, err)
|
||||
continue
|
||||
}
|
||||
if _, err := db.Admin("admin_instances").UpdateOne(ctx,
|
||||
bson.M{"instance_id": inst.InstanceID},
|
||||
bson.M{"$addToSet": bson.M{"notices_sent": due}}); err != nil {
|
||||
log.Printf("lifecycle: record link notice %s for %s: %v", due, inst.InstanceID, 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
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// 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 "gitea.hostxtra.co.uk/mrhid6/vantage/shared/mail"
|
||||
|
||||
// Default is admin's sender. Set once by main; read everywhere else.
|
||||
var Default mail.Sender
|
||||
|
||||
func Init(s mail.Sender) { Default = s }
|
||||
|
||||
// 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() }
|
||||
@@ -0,0 +1,300 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"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"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
)
|
||||
|
||||
// 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
|
||||
// on the absence of what they write. There is no migrations collection in
|
||||
// admin, and adding one for two `$exists: false` queries would be more
|
||||
// machinery than the job deserves.
|
||||
//
|
||||
// It lives in models rather than db for the same reason SeedPlans does: db is
|
||||
// the connection layer and importing models there is an import cycle.
|
||||
func Backfill(ctx context.Context) error {
|
||||
// Pass 1: every existing customer_user created their own account, so they
|
||||
// are all owners. A row with no account_role would otherwise be able to do
|
||||
// nothing at all once the guards land — including managing the account it
|
||||
// created.
|
||||
res, err := db.Admin("customer_users").UpdateMany(ctx,
|
||||
bson.M{"account_role": bson.M{"$exists": false}},
|
||||
bson.M{"$set": bson.M{"account_role": AccountRoleOwner}})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if res.ModifiedCount > 0 {
|
||||
log.Printf("backfill: set account_role=owner on %d customer_users", res.ModifiedCount)
|
||||
}
|
||||
|
||||
// Pass 2: phase 2 created cloud instances and their owners without an
|
||||
// instance_members row, because the collection did not exist. Reconstruct
|
||||
// one per instance from the control-plane owner it actually created.
|
||||
cur, err := db.Admin("admin_instances").Find(ctx, bson.M{
|
||||
"deployment": license.DeploymentCloud,
|
||||
"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("instance_members").CountDocuments(ctx,
|
||||
bson.M{"instance_id": inst.InstanceID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Only an hq-sourced owner can be reconstructed: a control-plane owner
|
||||
// with no hq_user_id was created inside the instance and belongs to
|
||||
// nobody on this side. Leaving it unrecorded is correct.
|
||||
var owner sharedmodels.User
|
||||
err = db.Control("users").FindOne(ctx, bson.M{
|
||||
"instance_id": inst.InstanceID,
|
||||
"role": sharedmodels.RoleOwner,
|
||||
"hq_user_id": bson.M{"$nin": bson.A{nil, ""}},
|
||||
}).Decode(&owner)
|
||||
if err != nil {
|
||||
if err != mongo.ErrNoDocuments {
|
||||
return err
|
||||
}
|
||||
log.Printf("backfill: instance %s has no hq-sourced owner; left unrecorded", inst.InstanceID)
|
||||
continue
|
||||
}
|
||||
|
||||
if _, err := db.Admin("instance_members").InsertOne(ctx, InstanceMember{
|
||||
MemberID: uuid.NewString(),
|
||||
AccountID: inst.AccountID,
|
||||
InstanceID: inst.InstanceID,
|
||||
CustomerUserID: owner.HQUserID,
|
||||
ControlUserID: owner.UserID,
|
||||
Role: sharedmodels.RoleOwner,
|
||||
Email: owner.Email,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
created++
|
||||
}
|
||||
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
|
||||
}
|
||||
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"
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
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 sixteen rows the four PAID plans need.
|
||||
//
|
||||
// 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},
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// Account roles.
|
||||
//
|
||||
// Deliberately the same three words as the control plane's own roles rather
|
||||
// than a second vocabulary: a customer who reads "admin" in the portal and
|
||||
// "admin" in their instance should not have to learn that they mean different
|
||||
// things. They govern different scopes — this one governs the HQ account —
|
||||
// but they mean the same thing about power.
|
||||
//
|
||||
// Billing stays owner-only. Owners and admins may invite people, create
|
||||
// instances and grant instance access.
|
||||
const (
|
||||
AccountRoleOwner = "owner"
|
||||
AccountRoleAdmin = "admin"
|
||||
AccountRoleMember = "member"
|
||||
)
|
||||
|
||||
func ValidAccountRole(r string) bool {
|
||||
switch r {
|
||||
case AccountRoleOwner, AccountRoleAdmin, AccountRoleMember:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// AccountRoleAtLeastAdmin is the single definition of "may manage people and
|
||||
// instances". Every guard calls this rather than comparing strings, so widening
|
||||
// the rule is one edit.
|
||||
func AccountRoleAtLeastAdmin(r string) bool {
|
||||
return r == AccountRoleOwner || r == AccountRoleAdmin
|
||||
}
|
||||
|
||||
// InstanceMember records that one HQ person holds a projected user inside one
|
||||
// cloud instance.
|
||||
//
|
||||
// It is admin's index of the projection, not the authority: the control-plane
|
||||
// `users` row IS the access. This row exists so the portal can list who is on
|
||||
// an instance without reading the control plane, and so a password change can
|
||||
// find every row to update without scanning every instance.
|
||||
//
|
||||
// ControlUserID is the projected users.user_id. Role is the role that user
|
||||
// holds INSIDE the instance, which is not the person's account role.
|
||||
type InstanceMember struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
|
||||
MemberID string `bson:"member_id" json:"member_id"`
|
||||
AccountID string `bson:"account_id" json:"account_id"`
|
||||
InstanceID string `bson:"instance_id" json:"instance_id"`
|
||||
CustomerUserID string `bson:"customer_user_id" json:"customer_user_id"`
|
||||
ControlUserID string `bson:"control_user_id" json:"control_user_id"`
|
||||
Role string `bson:"role" json:"role"`
|
||||
Email string `bson:"email" json:"email"`
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
// Package models holds admin's own documents.
|
||||
//
|
||||
// These are admin-owned and never shared with the control plane. The two
|
||||
// structs that ARE shared — Instance and User on the control-plane side — come
|
||||
// from shared/models, so there is no second copy of those shapes to drift.
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
StatusActive = "active"
|
||||
StatusLapsed = "lapsed"
|
||||
StatusCancelled = "cancelled"
|
||||
|
||||
// StatusDeleted marks an instance the control plane has reaped. The row is
|
||||
// kept because the licence history references it and support questions
|
||||
// outlive the instance.
|
||||
StatusDeleted = "deleted"
|
||||
)
|
||||
|
||||
// Account statuses.
|
||||
const (
|
||||
AccountActive = "active"
|
||||
AccountSuspended = "suspended"
|
||||
)
|
||||
|
||||
// Licence issuance reasons. These end up in support conversations, so they are
|
||||
// stable identifiers rather than prose.
|
||||
const (
|
||||
ReasonNew = "new"
|
||||
ReasonRenewal = "renewal"
|
||||
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.
|
||||
//
|
||||
// This is an abuse SIGNAL, not abuse prevention — offline licences cannot be
|
||||
// revoked, so a determined customer is not stopped by a counter. Its real job is
|
||||
// to put a human in front of the fourth attempt.
|
||||
const MaxRelinksPerTerm = 3
|
||||
|
||||
// GracePeriod is added to every licence expiry beyond the billing period end,
|
||||
// so a renewal webhook arriving slightly late does not create a gap in which a
|
||||
// paying customer's instance goes read-only.
|
||||
const GracePeriod = 3 * 24 * time.Hour
|
||||
|
||||
// RenewWindow is how long before expiry a Free instance may be renewed.
|
||||
//
|
||||
// Renewal stays available after expiry too, right up until the reaper takes the
|
||||
// instance, so the same button rescues a lapsed instance instead of needing a
|
||||
// second mechanism.
|
||||
const RenewWindow = 7 * 24 * time.Hour
|
||||
|
||||
type Account struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
|
||||
AccountID string `bson:"account_id" json:"account_id"`
|
||||
Name string `bson:"name" json:"name"`
|
||||
BillingEmail string `bson:"billing_email" json:"billing_email"`
|
||||
PaddleCustomerID string `bson:"paddle_customer_id,omitempty" json:"paddle_customer_id,omitempty"`
|
||||
Status string `bson:"status" json:"status"`
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
// Instance is admin's record of one deployment.
|
||||
//
|
||||
// For cloud, InstanceID equals the control-plane instance_id. For self-hosted it
|
||||
// is the UUID the customer pasted — their database is theirs, and we cannot see
|
||||
// it, so this row is the only thing that exists on our side.
|
||||
type Instance struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
|
||||
InstanceID string `bson:"instance_id" json:"instance_id"`
|
||||
AccountID string `bson:"account_id" json:"account_id"`
|
||||
Name string `bson:"name" json:"name"`
|
||||
Slug string `bson:"slug,omitempty" json:"slug,omitempty"`
|
||||
Deployment string `bson:"deployment" json:"deployment"`
|
||||
Tier string `bson:"tier,omitempty" json:"tier,omitempty"`
|
||||
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"`
|
||||
// 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"`
|
||||
// Placeholder is true while a self-hosted instance row exists only so a
|
||||
// checkout has something to attach custom_data to, before the customer has
|
||||
// pasted their install's real UUID. Cleared when the instance is linked.
|
||||
Placeholder bool `bson:"placeholder,omitempty" json:"placeholder,omitempty"`
|
||||
// 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
|
||||
// the old one. Nothing here is ever edited or deleted: when a support question
|
||||
// arrives about why an instance stopped working on a given date, the answer has
|
||||
// to still be in the table.
|
||||
type License struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
|
||||
LicenseID string `bson:"license_id" json:"license_id"`
|
||||
InstanceID string `bson:"instance_id" json:"instance_id"`
|
||||
AccountID string `bson:"account_id" json:"account_id"`
|
||||
Tier string `bson:"tier" json:"tier"`
|
||||
Deployment string `bson:"deployment" json:"deployment"`
|
||||
Limits license.Limits `bson:"limits" json:"limits"`
|
||||
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:"-"`
|
||||
SupersededBy string `bson:"superseded_by,omitempty" json:"superseded_by,omitempty"`
|
||||
IssuedBy string `bson:"issued_by" json:"issued_by"`
|
||||
Reason string `bson:"reason" json:"reason"`
|
||||
}
|
||||
|
||||
type Subscription struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
|
||||
SubscriptionID string `bson:"subscription_id" json:"subscription_id"`
|
||||
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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// 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:"-"`
|
||||
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 {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
|
||||
UserID string `bson:"user_id" json:"user_id"`
|
||||
Email string `bson:"email" json:"email"`
|
||||
PasswordHash string `bson:"password_hash" json:"-"`
|
||||
Name string `bson:"name" json:"name"`
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
// CustomerUser is one person on an HQ account.
|
||||
//
|
||||
// AccountRole governs what they may do to the ACCOUNT — invite people, create
|
||||
// instances, grant access. It says nothing about what they may do inside any
|
||||
// instance; that is the role on their InstanceMember row.
|
||||
type CustomerUser struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
|
||||
UserID string `bson:"user_id" json:"user_id"`
|
||||
AccountID string `bson:"account_id" json:"account_id"`
|
||||
Email string `bson:"email" json:"email"`
|
||||
PasswordHash string `bson:"password_hash" json:"-"`
|
||||
AccountRole string `bson:"account_role" json:"account_role"`
|
||||
VerifiedAt *time.Time `bson:"verified_at,omitempty" json:"verified_at,omitempty"`
|
||||
VerifyTokenHash string `bson:"verify_token_hash,omitempty" json:"-"`
|
||||
VerifyTokenExpiry *time.Time `bson:"verify_token_expiry,omitempty" json:"-"`
|
||||
// HQSyncFailedAt is set when a password change could not be written to
|
||||
// every projected control-plane row. It is visibility only — hqsync repairs
|
||||
// by comparing hashes, not by reading this field.
|
||||
HQSyncFailedAt *time.Time `bson:"hq_sync_failed_at,omitempty" json:"hq_sync_failed_at,omitempty"`
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
type AuditEntry struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
|
||||
Actor string `bson:"actor" json:"actor"`
|
||||
Action string `bson:"action" json:"action"`
|
||||
AccountID string `bson:"account_id,omitempty" json:"account_id,omitempty"`
|
||||
Target string `bson:"target,omitempty" json:"target,omitempty"`
|
||||
Detail string `bson:"detail,omitempty" json:"detail,omitempty"`
|
||||
IP string `bson:"ip,omitempty" json:"ip,omitempty"`
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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/options"
|
||||
)
|
||||
|
||||
// SeedPlans inserts the six (deployment, tier) rows from shared/license on first
|
||||
// boot.
|
||||
//
|
||||
// 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 _, 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 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{"deployment": deployment, "tier": tier}).Decode(&p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
func now() time.Time { return time.Now().UTC() }
|
||||
@@ -0,0 +1,54 @@
|
||||
// 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
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
.next
|
||||
.env
|
||||
*.lic
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
.next
|
||||
next-env.d.ts
|
||||
.env
|
||||
*.lic
|
||||
@@ -0,0 +1,50 @@
|
||||
FROM node:26-alpine AS deps
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install
|
||||
|
||||
FROM node:26-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# Baked in at build time and must be reachable from the BROWSER, and present in
|
||||
# admin's ADMIN_ORIGIN. Wrong here means every request fails at runtime.
|
||||
ARG NEXT_PUBLIC_ADMIN_API_URL=http://localhost:8083
|
||||
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
|
||||
|
||||
RUN npm run build
|
||||
|
||||
FROM node:26-alpine AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { API_BASE, NotConnected, api } from "@/lib/api";
|
||||
import { NotConnectedPanel } from "@/components/NotConnected";
|
||||
import { PageFrame, RailCard, RailFacts } from "@/components/PageFrame";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { ManageBillingButton } from "@/components/ManageBillingButton";
|
||||
import { formatDate } from "@/lib/format";
|
||||
|
||||
export default function BillingPage() {
|
||||
const subs = useQuery({ queryKey: ["subscriptions"], queryFn: api.subscriptions });
|
||||
const account = useQuery({ queryKey: ["account"], queryFn: api.account });
|
||||
|
||||
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;
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<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
|
||||
}
|
||||
/>
|
||||
|
||||
<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>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{rows.length === 0 ? (
|
||||
<p className="rounded border border-rule bg-panel p-5 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.68rem] uppercase tracking-[0.1em] text-ink-3">
|
||||
<th className="px-4 py-2.5 font-normal">Instance</th>
|
||||
<th className="px-4 py-2.5 font-normal">Plan</th>
|
||||
<th className="px-4 py-2.5 font-normal">Term</th>
|
||||
<th className="px-4 py-2.5 font-normal">Status</th>
|
||||
<th className="px-4 py-2.5 font-normal">Renews</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((s) => (
|
||||
<tr
|
||||
key={s.subscription_id}
|
||||
className="border-b border-rule-soft last:border-0"
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
{nameFor(s.instance_id) ?? (
|
||||
<span className="text-ink-3">Not linked yet</span>
|
||||
)}
|
||||
</td>
|
||||
<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>
|
||||
)}
|
||||
</PageFrame>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { API_BASE, ApiError, NotConnected, api } from "@/lib/api";
|
||||
import { NotConnectedPanel } from "@/components/NotConnected";
|
||||
import { LicenceDelivery } from "@/components/LicenceDelivery";
|
||||
import { MembersPanel } from "@/components/MembersPanel";
|
||||
import { RelinkPanel } from "@/components/RelinkPanel";
|
||||
import { StatePill } from "@/components/StatePill";
|
||||
import { PageFrame, RailCard, RailFacts } from "@/components/PageFrame";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { formatDate, licenceState, limitLabel } from "@/lib/format";
|
||||
|
||||
export default function InstancePage() {
|
||||
const id = String(useParams().id);
|
||||
const router = useRouter();
|
||||
const qc = useQueryClient();
|
||||
const [relinkError, setRelinkError] = useState<string | undefined>();
|
||||
|
||||
const account = useQuery({ queryKey: ["account"], queryFn: api.account });
|
||||
const licence = useQuery({
|
||||
queryKey: ["license", id],
|
||||
queryFn: () => api.license(id),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const relink = useMutation({
|
||||
mutationFn: (newId: string) => api.relink(id, newId),
|
||||
onSuccess: (lic) => {
|
||||
qc.invalidateQueries({ queryKey: ["account"] });
|
||||
router.replace(`/instances/${lic.instance_id}`);
|
||||
},
|
||||
onError: (err) =>
|
||||
setRelinkError(err instanceof ApiError ? err.message : "Relink failed. Try again."),
|
||||
});
|
||||
|
||||
if (account.error instanceof NotConnected) return <NotConnectedPanel url={API_BASE} />;
|
||||
|
||||
const instance = account.data?.instances.find((i) => i.instance_id === id);
|
||||
if (account.isLoading) return <p className="text-ink-3">Loading…</p>;
|
||||
if (!instance) {
|
||||
// Says "not on your account" rather than "does not exist": the backend
|
||||
// answers 404 for another account's instance, and confirming existence
|
||||
// here would undo that.
|
||||
return <p className="text-ink-2">That instance is not on your account.</p>;
|
||||
}
|
||||
|
||||
const lic = licence.data;
|
||||
const state = licenceState(lic?.expires_at, Boolean(lic));
|
||||
const cloud = instance.deployment === "cloud";
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<PageHeader
|
||||
back={{ href: "/", label: "Overview" }}
|
||||
title={instance.name || "Unnamed instance"}
|
||||
subtitle={`${cloud ? "Cloud" : "Self-hosted"}${
|
||||
instance.tier ? ` · ${instance.tier.replace("_", " ")}` : ""
|
||||
} · created ${formatDate(instance.created_at)}`}
|
||||
record={[
|
||||
{ key: "Instance", value: instance.instance_id, copy: true },
|
||||
...(lic ? [{ key: "Licence", value: lic.license_id, copy: true }] : []),
|
||||
]}
|
||||
status={<StatePill state={state} />}
|
||||
/>
|
||||
|
||||
<PageFrame
|
||||
aside={
|
||||
<>
|
||||
<RailCard title="Licence">
|
||||
{lic ? (
|
||||
<RailFacts
|
||||
rows={[
|
||||
{ label: "Tier", value: lic.tier.replace("_", " ") },
|
||||
{ label: "Issued", value: formatDate(lic.issued_at) },
|
||||
{ label: "Expires", value: formatDate(lic.expires_at) },
|
||||
{ label: "Reason", value: lic.reason },
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-[0.82rem] text-ink-2">
|
||||
No licence issued yet.
|
||||
</p>
|
||||
)}
|
||||
</RailCard>
|
||||
|
||||
{lic && (
|
||||
<RailCard title="Included">
|
||||
<RailFacts
|
||||
rows={[
|
||||
{
|
||||
label: "Servers",
|
||||
value: limitLabel(lic.limits.max_servers),
|
||||
},
|
||||
{
|
||||
label: "Secret groups",
|
||||
value: limitLabel(lic.limits.max_secret_groups),
|
||||
},
|
||||
{
|
||||
label: "Channels",
|
||||
value: limitLabel(lic.limits.max_channels),
|
||||
},
|
||||
{
|
||||
label: "Features",
|
||||
value: lic.features.join(", ") || "none",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</RailCard>
|
||||
)}
|
||||
|
||||
{!cloud && (
|
||||
<RailCard title="Moves">
|
||||
<RailFacts
|
||||
rows={[
|
||||
{
|
||||
label: "Relinks used",
|
||||
value: `${instance.relink_count} of ${
|
||||
account.data?.max_relinks ?? 3
|
||||
}`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<p className="text-[0.82rem] text-ink-2">
|
||||
Moving a licence to a different install counts as one.
|
||||
</p>
|
||||
</RailCard>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
|
||||
{lic && !cloud && (
|
||||
<>
|
||||
<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)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!lic && (
|
||||
<p className="rounded border border-rule bg-panel p-5 text-ink-2">
|
||||
No licence has been issued for this instance yet.
|
||||
</p>
|
||||
)}
|
||||
</PageFrame>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"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,
|
||||
claimId,
|
||||
}: {
|
||||
onLinked: (instanceId: string) => void;
|
||||
// The PAID placeholder awaiting its real install UUID: claim it in place. The
|
||||
// name was chosen at checkout, so it is not asked for again. Self-hosted Free
|
||||
// is created on the purchase page instead, not here.
|
||||
claimId: string;
|
||||
}) {
|
||||
const [id, setId] = 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.claimLink(claimId, value);
|
||||
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’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>.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Button type="submit" disabled={busy} className="justify-self-start">
|
||||
{busy ? "Linking…" : "Link and issue licence"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { LinkForm } from "./LinkForm";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
|
||||
export default function LinkPage() {
|
||||
const router = useRouter();
|
||||
const qc = useQueryClient();
|
||||
// This page only claims a PAID placeholder's real install UUID. Self-hosted
|
||||
// Free is created on the purchase page, so with no placeholder to claim there
|
||||
// is nothing to do here — send them there.
|
||||
const claimId = useSearchParams().get("claim") ?? undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (!claimId) router.replace("/purchase");
|
||||
}, [claimId, router]);
|
||||
|
||||
if (!claimId) return null;
|
||||
|
||||
return (
|
||||
<div className="grid max-w-2xl gap-6">
|
||||
<PageHeader
|
||||
back={{ href: "/", label: "Overview" }}
|
||||
title="Link an install"
|
||||
subtitle="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."
|
||||
/>
|
||||
<LinkForm
|
||||
claimId={claimId}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
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">
|
||||
<AppBar links={LINKS} context={<AccountName />} />
|
||||
<main className="mx-auto max-w-rail px-5 py-7">{children}</main>
|
||||
</RequireKind>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
"use client";
|
||||
|
||||
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 { InstanceRecord } from "@/components/InstanceRecord";
|
||||
import { PageFrame, RailCard, RailFacts } from "@/components/PageFrame";
|
||||
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 ?? [])
|
||||
.filter((i) => i.current_license)
|
||||
.map((i) => ({
|
||||
queryKey: ["license", i.instance_id],
|
||||
queryFn: () => api.license(i.instance_id),
|
||||
})),
|
||||
});
|
||||
|
||||
if (error instanceof NotConnected) return <NotConnectedPanel url={API_BASE} />;
|
||||
if (isLoading || !data) return <p className="text-ink-3">Loading your account…</p>;
|
||||
|
||||
const byInstance = new Map<string, License>();
|
||||
licences.forEach((q) => {
|
||||
if (q.data) byInstance.set(q.data.instance_id, q.data);
|
||||
});
|
||||
|
||||
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.
|
||||
const attention = live.flatMap((i) => {
|
||||
const lic = byInstance.get(i.instance_id);
|
||||
const state = licenceState(lic?.expires_at, Boolean(lic));
|
||||
if (state === "none")
|
||||
return [{ id: i.instance_id, text: `${i.name || "An instance"} is not linked`, note: "" }];
|
||||
if (state === "expired")
|
||||
return [{ id: i.instance_id, text: `${i.name} has expired`, note: "now" }];
|
||||
if (state === "warn")
|
||||
return [
|
||||
{
|
||||
id: i.instance_id,
|
||||
text: `${i.name} expires`,
|
||||
note: `${daysRemaining(lic!.expires_at)}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-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
|
||||
}
|
||||
/>
|
||||
|
||||
{live.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 run Vantage on your own server and get its licence — free
|
||||
or paid — from the purchase page.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2.5">
|
||||
<LinkButton href="/purchase">Buy a plan</LinkButton>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<PageFrame
|
||||
aside={
|
||||
<>
|
||||
{attention.length > 0 && (
|
||||
<RailCard title="Needs you" count={attention.length}>
|
||||
<ul className="grid gap-2">
|
||||
{attention.map((a) => (
|
||||
<li
|
||||
key={a.id}
|
||||
className="flex items-center justify-between gap-2.5 text-[0.82rem] text-ink-2"
|
||||
>
|
||||
<span>{a.text}</span>
|
||||
{a.note && (
|
||||
<span className="font-mono text-[0.64rem] uppercase tracking-[0.08em] text-warn">
|
||||
{a.note}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</RailCard>
|
||||
)}
|
||||
|
||||
<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>
|
||||
|
||||
{/*
|
||||
* 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>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{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,798 @@
|
||||
"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";
|
||||
|
||||
/* 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;
|
||||
|
||||
/* Human labels for feature keys. The catalogue names them by key; this is the
|
||||
* one place the customer-facing wording lives. */
|
||||
const FEATURE_LABEL: Record<string, string> = {
|
||||
console: "Browser console",
|
||||
oidc: "Single sign-on",
|
||||
};
|
||||
const FEATURE_DESC: Record<string, string> = {
|
||||
console: "In-browser SSH, RDP and VNC sessions",
|
||||
oidc: "OIDC sign-in for your whole team",
|
||||
};
|
||||
function featureLabel(key: string) {
|
||||
return FEATURE_LABEL[key] ?? key;
|
||||
}
|
||||
|
||||
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."),
|
||||
});
|
||||
|
||||
const startCheckout = useMutation({
|
||||
mutationFn: async () => {
|
||||
const trimmed = name.trim();
|
||||
const r = dep === "cloud" ? await api.createCloudCheckout(trimmed) : await api.createSelfHosted(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."),
|
||||
});
|
||||
|
||||
const claim = useMutation({
|
||||
mutationFn: () => api.claimLink(pending!.instanceId, uuid.trim()),
|
||||
onSuccess: () => router.push("/"),
|
||||
onError: (e) => setError(e instanceof ApiError ? e.message : "Could not link the install."),
|
||||
});
|
||||
|
||||
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={FEATURE_DESC[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>
|
||||
)}
|
||||
|
||||
{selfHostedFree && (
|
||||
<Block n={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">
|
||||
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’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}
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
startCheckout.mutate();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Phase B: after the checkout has been opened. */}
|
||||
{pending?.deployment === "self_hosted" && (
|
||||
<div className="grid gap-2 border-t border-rule-soft pt-3">
|
||||
<p className="text-[0.8rem] text-ink-2">Once payment clears, paste the instance ID your install reports (Settings → Licence) to receive your licence.</p>
|
||||
<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"
|
||||
/>
|
||||
<Cta
|
||||
label={claim.isPending ? "Linking…" : "Link and issue licence"}
|
||||
disabled={!uuid.trim() || claim.isPending}
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
claim.mutate();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{pending?.deployment === "cloud" && (
|
||||
<div className="grid gap-2 border-t border-rule-soft pt-3">
|
||||
<p className="text-[0.8rem] text-ink-2">Your instance is being set up. 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
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("");
|
||||
const [next, setNext] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [done, setDone] = useState<string | null>(null);
|
||||
|
||||
const change = useMutation({
|
||||
mutationFn: () => api.changePassword(current, next),
|
||||
onSuccess: (res) => {
|
||||
setCurrent("");
|
||||
setNext("");
|
||||
setDone(
|
||||
res.propagation_pending
|
||||
? "Password changed. One of your instances could not be updated just now; it will catch up within fifteen minutes."
|
||||
: "Password changed everywhere.",
|
||||
);
|
||||
},
|
||||
onError: (e) =>
|
||||
setError(e instanceof ApiError ? e.message : "Something went wrong. Try again."),
|
||||
});
|
||||
|
||||
return (
|
||||
<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"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setDone(null);
|
||||
change.mutate();
|
||||
}}
|
||||
>
|
||||
<Field
|
||||
label="Current password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={current}
|
||||
onChange={(e) => setCurrent(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Field
|
||||
label="New password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={next}
|
||||
onChange={(e) => setNext(e.target.value)}
|
||||
required
|
||||
minLength={12}
|
||||
hint="At least 12 characters."
|
||||
error={error ?? undefined}
|
||||
/>
|
||||
{done && <p className="text-[0.9rem] text-valid">{done}</p>}
|
||||
<Button type="submit" disabled={change.isPending || next.length < 12}>
|
||||
{change.isPending ? "Changing…" : "Change password"}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
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 { 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 users = useQuery({ queryKey: ["account-users"], queryFn: api.accountUsers });
|
||||
const refresh = () => qc.invalidateQueries({ queryKey: ["account-users"] });
|
||||
const fail = (e: unknown) =>
|
||||
setError(e instanceof ApiError ? e.message : "Something went wrong. Try again.");
|
||||
|
||||
const invite = useMutation({
|
||||
mutationFn: () => api.invite(email.trim().toLowerCase(), role),
|
||||
onSuccess: () => {
|
||||
setEmail("");
|
||||
setRole("member");
|
||||
refresh();
|
||||
},
|
||||
onError: fail,
|
||||
});
|
||||
const setRoleFor = useMutation({
|
||||
mutationFn: (v: { id: string; role: AccountRole }) => api.setAccountRole(v.id, v.role),
|
||||
onSuccess: refresh,
|
||||
onError: fail,
|
||||
});
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) => api.removeAccountUser(id),
|
||||
onSuccess: refresh,
|
||||
onError: fail,
|
||||
});
|
||||
|
||||
if (users.error instanceof NotConnected) return <NotConnectedPanel url={API_BASE} />;
|
||||
|
||||
const myRole = session?.account_role;
|
||||
const canManage = myRole === "owner" || myRole === "admin";
|
||||
const assignable = myRole === "owner" ? ROLES : ROLES.filter((r) => r !== "owner");
|
||||
|
||||
return (
|
||||
<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="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>
|
||||
<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>
|
||||
)}
|
||||
|
||||
<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 && (
|
||||
<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>
|
||||
</div>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { InvitePanel } from "./InvitePanel";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
|
||||
export default function UsersPage() {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
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";
|
||||
|
||||
export function AccountSearch() {
|
||||
const [q, setQ] = useState("");
|
||||
const { data, isFetching } = useQuery({
|
||||
queryKey: ["staff-accounts", q],
|
||||
queryFn: () => api.staff.accounts(q || undefined),
|
||||
});
|
||||
|
||||
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"
|
||||
>
|
||||
{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>
|
||||
))}
|
||||
</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’s
|
||||
email.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
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";
|
||||
|
||||
export default function AccountDetailPage() {
|
||||
const id = String(useParams().id);
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["staff-account", id],
|
||||
queryFn: () => api.staff.account(id),
|
||||
});
|
||||
|
||||
if (isLoading || !data) return <p className="text-ink-3">Loading…</p>;
|
||||
|
||||
return (
|
||||
<div className="grid gap-8">
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { AccountSearch } from "./AccountSearch";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
|
||||
export default function AccountsPage() {
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<PageHeader
|
||||
title="Accounts"
|
||||
subtitle="Search by name, email, Paddle ID or instance UUID."
|
||||
/>
|
||||
<AccountSearch />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
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";
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<PageHeader
|
||||
title="Audit"
|
||||
subtitle="Every mutating action across every account, newest first."
|
||||
record={[{ key: "Showing", value: `${rows.length} of ${(data ?? []).length}` }]}
|
||||
/>
|
||||
<Field
|
||||
label="Filter"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
hint="Action, actor or target."
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
"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 { 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="space-y-6">
|
||||
{groups.map((g) => {
|
||||
const [deployment, tier] = g.split("/");
|
||||
const terms = termsFor(deployment);
|
||||
return (
|
||||
<section key={g} className="space-y-2">
|
||||
<h2 className="text-[0.95rem] font-medium text-ink">
|
||||
{deployment === "cloud" ? "Cloud" : "Self-Hosted"}{" "}
|
||||
{tier}
|
||||
</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[42rem] border-collapse text-[0.82rem]">
|
||||
<thead>
|
||||
<tr className="border-b border-rule text-left text-ink-3">
|
||||
<th className="py-2 pr-3 font-normal">Component</th>
|
||||
{ENVS.map((env) =>
|
||||
terms.map((t) => (
|
||||
<th
|
||||
key={`${env}-${t}`}
|
||||
className="py-2 pr-3 font-normal"
|
||||
>
|
||||
{env} / {t}
|
||||
</th>
|
||||
)),
|
||||
)}
|
||||
<th className="py-2 font-normal" />
|
||||
</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}
|
||||
className="border-b border-rule/60"
|
||||
>
|
||||
<td className="py-2 pr-3 text-ink">
|
||||
{componentLabel(r)}
|
||||
</td>
|
||||
{ENVS.map((env) =>
|
||||
terms.map((t) => (
|
||||
<td
|
||||
key={`${env}-${t}`}
|
||||
className="py-2 pr-3"
|
||||
>
|
||||
<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 px-2 py-1 font-mono text-[0.78rem] text-ink"
|
||||
/>
|
||||
</td>
|
||||
)),
|
||||
)}
|
||||
<td className="py-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
!dirty || save.isPending
|
||||
}
|
||||
onClick={() =>
|
||||
save.mutate({
|
||||
...r,
|
||||
price_ids: ids,
|
||||
})
|
||||
}
|
||||
className="rounded border border-accent/50 px-2.5 py-1 text-[0.78rem] text-accent disabled:opacity-40"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</PageFrame>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { ApiError, api, type Tier } from "@/lib/api";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Field } from "@/components/Field";
|
||||
|
||||
export function IssuePanel({
|
||||
instanceId,
|
||||
}: {
|
||||
instanceId: string;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const [tier, setTier] = useState<Tier>("professional");
|
||||
const [term, setTerm] = useState("annual");
|
||||
const [newId, setNewId] = useState("");
|
||||
const [error, setError] = useState<string | undefined>();
|
||||
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: ["staff-instance", instanceId] });
|
||||
|
||||
const issue = useMutation({
|
||||
mutationFn: () => api.staff.issue(instanceId, { tier, term, reason: "manual" }),
|
||||
onSuccess: invalidate,
|
||||
onError: (e) => setError(e instanceof ApiError ? e.message : "Issue failed."),
|
||||
});
|
||||
|
||||
const relink = useMutation({
|
||||
mutationFn: () => api.staff.relink(instanceId, newId.trim()),
|
||||
onSuccess: invalidate,
|
||||
onError: (e) => setError(e instanceof ApiError ? e.message : "Relink failed."),
|
||||
});
|
||||
|
||||
return (
|
||||
<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"
|
||||
>
|
||||
<option value="free">Free</option>
|
||||
<option value="professional">Professional</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"
|
||||
>
|
||||
<option value="annual">Annual</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
</select>
|
||||
</label>
|
||||
<Button type="button" onClick={() => issue.mutate()} disabled={issue.isPending}>
|
||||
{issue.isPending ? "Issuing…" : "Issue licence"}
|
||||
</Button>
|
||||
</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()}
|
||||
>
|
||||
Relink
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-[0.82rem] text-expired">{error}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
"use client";
|
||||
|
||||
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 Deployment, type InjectionState } from "@/lib/api";
|
||||
import { Ledger } from "@/components/Ledger";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import PlanConfigurator, { type PlanChoice } from "@/components/PlanConfigurator";
|
||||
import { IssuePanel } from "./IssuePanel";
|
||||
|
||||
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",
|
||||
tone: "text-warn",
|
||||
},
|
||||
missing: { label: "No matching instance in the control plane", tone: "text-expired" },
|
||||
none_issued: { label: "Nothing issued yet, so nothing to inject", tone: "text-ink-3" },
|
||||
};
|
||||
|
||||
export default function StaffInstancePage() {
|
||||
const id = String(useParams().id);
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["staff-instance", id],
|
||||
queryFn: () => api.staff.instance(id),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
if (isLoading || !data) return <p className="text-ink-3">Loading…</p>;
|
||||
|
||||
const inj = data.injection.state ? INJECTION[data.injection.state] : undefined;
|
||||
|
||||
return (
|
||||
<div className="grid gap-8">
|
||||
<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 }]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
{data.injection.applicable && inj && (
|
||||
<p className={clsx("font-mono text-[0.72rem]", inj.tone)}>{inj.label}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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} />
|
||||
</section>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { RequireKind } from "@/lib/session";
|
||||
import { AppBar, type NavLink } from "@/components/AppBar";
|
||||
|
||||
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">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
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 { PageHeader } from "@/components/PageHeader";
|
||||
|
||||
export default function LicensesPage() {
|
||||
const [tier, setTier] = useState<"" | Tier>("");
|
||||
const [reason, setReason] = useState("");
|
||||
const { data } = useQuery({ queryKey: ["staff-licenses"], queryFn: () => api.staff.licenses() });
|
||||
|
||||
// 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),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<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}` }]}
|
||||
/>
|
||||
<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="enterprise">Enterprise</option>
|
||||
<option value="self_hosted">Self-Hosted (legacy)</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>
|
||||
{rows.length === 0 && (
|
||||
<p className="px-4 py-6 text-ink-3">No licences match those filters.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
"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 { PageFrame, RailCard, RailFacts } from "@/components/PageFrame";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { StatePill } from "@/components/StatePill";
|
||||
import { LinkButton } from "@/components/Button";
|
||||
import { daysRemaining, formatStamp } from "@/lib/format";
|
||||
|
||||
const HOURS_48 = 48 * 3600_000;
|
||||
|
||||
export default function StaffDashboard() {
|
||||
const injection = useQuery({ queryKey: ["injection"], queryFn: api.staff.injectionHealth });
|
||||
const expiring = useQuery({
|
||||
queryKey: ["instances", "expiring"],
|
||||
queryFn: () => api.staff.instances({ expiring: "true" }),
|
||||
});
|
||||
const pastDue = useQuery({
|
||||
queryKey: ["subs", "past_due"],
|
||||
queryFn: () => api.staff.subscriptions("past_due"),
|
||||
});
|
||||
const unlinked = useQuery({
|
||||
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 failed = injection.data?.count ?? 0;
|
||||
const instances = allInstances.data ?? [];
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<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={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)
|
||||
: "",
|
||||
}))}
|
||||
/>
|
||||
<Queue
|
||||
title="Expiring ≤ 14 days"
|
||||
tone="warn"
|
||||
count={expiring.data?.length ?? 0}
|
||||
items={(expiring.data ?? []).slice(0, 4).map((i) => ({
|
||||
label: i.name || i.instance_id,
|
||||
href: `/staff/instances/${i.instance_id}`,
|
||||
meta: i.tier ?? "",
|
||||
}))}
|
||||
/>
|
||||
<Queue
|
||||
title="Past due"
|
||||
tone="expired"
|
||||
count={pastDue.data?.length ?? 0}
|
||||
items={(pastDue.data ?? []).slice(0, 4).map((s) => ({
|
||||
label: s.instance_id || s.account_id,
|
||||
href: `/staff/accounts/${s.account_id}`,
|
||||
meta: `${daysRemaining(s.current_period_end)}d`,
|
||||
}))}
|
||||
/>
|
||||
<Queue
|
||||
title="Unlinked > 48h"
|
||||
tone="accent"
|
||||
count={stale.length}
|
||||
items={stale.slice(0, 4).map((i) => ({
|
||||
label: i.name || i.instance_id,
|
||||
href: `/staff/accounts/${i.account_id}`,
|
||||
meta: `${Math.floor((Date.now() - new Date(i.created_at).getTime()) / 86_400_000)}d`,
|
||||
}))}
|
||||
/>
|
||||
</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>
|
||||
}
|
||||
>
|
||||
<section className="overflow-hidden rounded border border-rule bg-panel">
|
||||
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-rule-soft bg-panel-2 px-4 py-3">
|
||||
<div>
|
||||
<h2 className="text-[0.95rem]">Recent activity</h2>
|
||||
<p className="text-[0.8rem] text-ink-2">
|
||||
Every licence issued, relinked or reaped, newest first.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/staff/audit"
|
||||
className="text-[0.82rem] font-semibold text-accent underline"
|
||||
>
|
||||
Full audit
|
||||
</Link>
|
||||
</header>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse text-left text-[0.86rem]">
|
||||
<thead>
|
||||
<tr className="border-b border-rule-soft font-mono text-[0.64rem] uppercase tracking-[0.11em] text-ink-3">
|
||||
<th className="px-4 py-2 font-normal">Time</th>
|
||||
<th className="px-4 py-2 font-normal">Action</th>
|
||||
<th className="px-4 py-2 font-normal">Target</th>
|
||||
<th className="px-4 py-2 font-normal">Actor</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(audit.data ?? []).slice(0, 12).map((e, n) => (
|
||||
<tr key={n} className="border-b border-rule-soft last:border-0">
|
||||
<td className="px-4 py-2.5 font-mono tabular-nums text-ink-2">
|
||||
{new Date(e.created_at).toISOString().slice(11, 16)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">{e.action}</td>
|
||||
<td className="px-4 py-2.5 text-ink-2">{e.target ?? "—"}</td>
|
||||
<td className="px-4 py-2.5 text-ink-3">{e.actor}</td>
|
||||
</tr>
|
||||
))}
|
||||
{audit.data?.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-6 text-ink-3">
|
||||
Nothing yet today.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</PageFrame>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { api, type Deployment, type Plan, type Tier } from "@/lib/api";
|
||||
import { ConfirmPlanChange } from "@/components/ConfirmPlanChange";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
|
||||
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="space-y-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 px-2 py-1.5 text-[0.85rem] text-ink"
|
||||
/>
|
||||
<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 px-2 py-1.5 text-[0.85rem] text-ink"
|
||||
>
|
||||
{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="rounded border border-accent/50 px-3 py-1.5 text-[0.85rem] text-accent disabled:opacity-40"
|
||||
>
|
||||
{saving ? "Saving…" : "Save allowances"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PlansPage() {
|
||||
const qc = useQueryClient();
|
||||
const plans = useQuery({ queryKey: ["plans"], queryFn: api.staff.plans });
|
||||
const licenses = useQuery({
|
||||
queryKey: ["staff-licenses"],
|
||||
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.deployment, p.tier, p),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["plans"] });
|
||||
setDraft(null);
|
||||
setSaving(null);
|
||||
},
|
||||
onError: () => setSaving(null),
|
||||
});
|
||||
|
||||
const original = plans.data?.find(
|
||||
(p) => p.deployment === draft?.deployment && p.tier === draft?.tier,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<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 && l.deployment === draft.deployment,
|
||||
).length
|
||||
}
|
||||
onConfirm={() => {
|
||||
setSaving(`${draft.deployment}/${draft.tier}`);
|
||||
save.mutate(draft);
|
||||
}}
|
||||
onCancel={() => setDraft(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(["cloud", "self_hosted"] as const).map((deployment: Deployment) => (
|
||||
<section key={deployment} className="space-y-3">
|
||||
<h2 className="text-[0.95rem] font-medium text-ink">
|
||||
{deployment === "cloud" ? "Cloud" : "Self-Hosted"}
|
||||
</h2>
|
||||
{(plans.data ?? [])
|
||||
.filter((p) => p.deployment === deployment)
|
||||
.map((p) => (
|
||||
<article
|
||||
key={`${p.deployment}/${p.tier}`}
|
||||
className="rounded-lg border border-rule bg-panel p-4"
|
||||
>
|
||||
<header className="mb-3 flex items-baseline justify-between gap-3">
|
||||
<h3 className="text-[0.9rem] font-medium text-ink">
|
||||
{p.name}
|
||||
</h3>
|
||||
<span className="font-mono text-[0.75rem] text-ink-3">
|
||||
{p.deployment}/{p.tier}
|
||||
</span>
|
||||
</header>
|
||||
<AllowanceForm
|
||||
plan={p}
|
||||
saving={saving === `${p.deployment}/${p.tier}`}
|
||||
onSave={(next: Plan) => setDraft(next)}
|
||||
/>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"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";
|
||||
|
||||
function AcceptForm() {
|
||||
const token = useSearchParams().get("token") ?? "";
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
const accept = useMutation({
|
||||
mutationFn: () => api.acceptInvite(token, password),
|
||||
onSuccess: () => setDone(true),
|
||||
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 (done)
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
<h1 className="text-3xl">You'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>
|
||||
);
|
||||
|
||||
return (
|
||||
<form
|
||||
className="grid max-w-md gap-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
accept.mutate();
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* ==========================================================================
|
||||
Vantage admin console design tokens.
|
||||
|
||||
Lines 8-97 below are site/app/globals.css's token blocks, copied verbatim:
|
||||
the marketing site and this console are one visual system. Change them in
|
||||
both apps in the same commit — nothing enforces the match automatically.
|
||||
|
||||
Light is the default because web/ is locked to dark, and telling the two
|
||||
apart at a glance is what stops a Reissue landing in the wrong tab. In dark
|
||||
mode the accent lifts to #5b9be8, which is nearer web/'s indigo, so the
|
||||
distinction leans on the ground rather than the hue.
|
||||
========================================================================== */
|
||||
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
|
||||
--ground: #eaedf3;
|
||||
--panel: #ffffff;
|
||||
--panel-2: #f4f6fa;
|
||||
--ink: #0a1b33;
|
||||
--ink-2: #41556f;
|
||||
--ink-3: #6c7f96;
|
||||
--rule: #cdd6e2;
|
||||
--rule-soft: #e0e6ef;
|
||||
--accent: #0b2a58;
|
||||
--accent-ink: #ffffff;
|
||||
--up: #2f8a60;
|
||||
--down: #c6462f;
|
||||
--pend: #b0801f;
|
||||
--shadow: 0 1px 0 rgba(10, 27, 51, 0.05), 0 18px 40px -26px rgba(10, 27, 51, 0.45);
|
||||
--logo: #0b2a58;
|
||||
|
||||
--sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
--mono: ui-monospace, "Cascadia Mono", "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
|
||||
|
||||
--s--1: clamp(0.76rem, 0.74rem + 0.1vw, 0.81rem);
|
||||
--s-0: clamp(1rem, 0.97rem + 0.14vw, 1.05rem);
|
||||
--s-1: clamp(1.16rem, 1.09rem + 0.32vw, 1.36rem);
|
||||
--s-2: clamp(1.5rem, 1.34rem + 0.74vw, 2rem);
|
||||
--s-3: clamp(2rem, 1.66rem + 1.6vw, 3.1rem);
|
||||
--s-4: clamp(2.6rem, 1.9rem + 3.3vw, 4.9rem);
|
||||
|
||||
--rail: 1200px;
|
||||
}
|
||||
|
||||
/* Dark tokens are defined once and applied through three selectors: the OS
|
||||
preference, and both explicit values of data-theme so the in-page toggle
|
||||
wins in either direction. */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--ground: #071628;
|
||||
--panel: #0d2138;
|
||||
--panel-2: #102842;
|
||||
--ink: #e4ecf6;
|
||||
--ink-2: #9fb3ca;
|
||||
--ink-3: #71879f;
|
||||
--rule: #1e3855;
|
||||
--rule-soft: #172c44;
|
||||
--accent: #5b9be8;
|
||||
--accent-ink: #04101f;
|
||||
--up: #4fb484;
|
||||
--down: #e2705a;
|
||||
--pend: #d6a63f;
|
||||
--shadow: 0 1px 0 rgba(0, 0, 0, 0.35), 0 20px 44px -26px rgba(0, 0, 0, 0.85);
|
||||
--logo: #7fb2f0;
|
||||
}
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
--ground: #071628;
|
||||
--panel: #0d2138;
|
||||
--panel-2: #102842;
|
||||
--ink: #e4ecf6;
|
||||
--ink-2: #9fb3ca;
|
||||
--ink-3: #71879f;
|
||||
--rule: #1e3855;
|
||||
--rule-soft: #172c44;
|
||||
--accent: #5b9be8;
|
||||
--accent-ink: #04101f;
|
||||
--up: #4fb484;
|
||||
--down: #e2705a;
|
||||
--pend: #d6a63f;
|
||||
--shadow: 0 1px 0 rgba(0, 0, 0, 0.35), 0 20px 44px -26px rgba(0, 0, 0, 0.85);
|
||||
--logo: #7fb2f0;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] {
|
||||
--ground: #eaedf3;
|
||||
--panel: #ffffff;
|
||||
--panel-2: #f4f6fa;
|
||||
--ink: #0a1b33;
|
||||
--ink-2: #41556f;
|
||||
--ink-3: #6c7f96;
|
||||
--rule: #cdd6e2;
|
||||
--rule-soft: #e0e6ef;
|
||||
--accent: #0b2a58;
|
||||
--accent-ink: #ffffff;
|
||||
--up: #2f8a60;
|
||||
--down: #c6462f;
|
||||
--pend: #b0801f;
|
||||
--shadow: 0 1px 0 rgba(10, 27, 51, 0.05), 0 18px 40px -26px rgba(10, 27, 51, 0.45);
|
||||
--logo: #0b2a58;
|
||||
}
|
||||
|
||||
/* Not in site/: the hatched sandbox badge and hover washes need a tinted fill,
|
||||
and deriving it at each use would drift. */
|
||||
:root {
|
||||
--accent-wash: rgba(11, 42, 88, 0.07);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--accent-wash: rgba(91, 155, 232, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
--accent-wash: rgba(91, 155, 232, 0.1);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] {
|
||||
--accent-wash: rgba(11, 42, 88, 0.07);
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--ground);
|
||||
color: var(--ink);
|
||||
font-family: var(--sans);
|
||||
font-size: var(--s-0);
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* site/'s heading treatment, which is what replaces a display face. */
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-weight: 800;
|
||||
line-height: 1.03;
|
||||
letter-spacing: -0.03em;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.92em;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 3px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.001ms !important;
|
||||
transition-duration: 0.001ms !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import { Providers } from "@/components/Providers";
|
||||
import { THEME_BOOT_SCRIPT } from "@/lib/theme";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
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, /signup, /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>
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
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";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [staff, setStaff] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [offline, setOffline] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const s = staff
|
||||
? await api.staffLogin(email, password)
|
||||
: await api.login(email, password);
|
||||
router.replace(s.kind === "staff" ? "/staff" : "/");
|
||||
} catch (err) {
|
||||
if (err instanceof NotConnected) setOffline(true);
|
||||
else if (err instanceof ApiError) setError(err.message);
|
||||
else setError("Sign in failed. Try again.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (offline)
|
||||
return (
|
||||
<Main>
|
||||
<NotConnectedPanel url={API_BASE} />
|
||||
</Main>
|
||||
);
|
||||
|
||||
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)}
|
||||
/>
|
||||
<Field
|
||||
label="Password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
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)}
|
||||
/>
|
||||
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>
|
||||
</form>
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
|
||||
function Main({ children }: { children: React.ReactNode }) {
|
||||
return <main className="mx-auto max-w-rail px-5 py-12">{children}</main>;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<main className="mx-auto max-w-rail px-5 py-12">
|
||||
<h1 className="text-3xl">Nothing here</h1>
|
||||
<p className="mt-2 text-ink-2">
|
||||
That page does not exist, or it belongs to an account you are not signed in to.
|
||||
</p>
|
||||
<Link href="/" className="mt-4 inline-block text-accent underline">
|
||||
Back to your account
|
||||
</Link>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
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";
|
||||
|
||||
function Verify() {
|
||||
const router = useRouter();
|
||||
const token = useSearchParams().get("token") ?? "";
|
||||
const { data, error, isLoading } = useQuery({
|
||||
queryKey: ["verify", token],
|
||||
queryFn: () => api.verify(token),
|
||||
enabled: token !== "",
|
||||
retry: false,
|
||||
});
|
||||
|
||||
// An invitation and a verification link are the same shape, and someone will
|
||||
// paste one into the other. The backend leaves an invite token unspent and
|
||||
// says so; send them where they can actually finish.
|
||||
const needsPassword = data?.needs_password === true;
|
||||
useEffect(() => {
|
||||
if (needsPassword) {
|
||||
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 (!token)
|
||||
return (
|
||||
<Message
|
||||
title="That link is incomplete"
|
||||
body="It is missing its token. Use the link in the email exactly as sent."
|
||||
/>
|
||||
);
|
||||
if (isLoading) return <Message title="Verifying…" body="One moment." />;
|
||||
if (error || !data?.verified)
|
||||
return (
|
||||
<Message
|
||||
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."
|
||||
/>
|
||||
);
|
||||
|
||||
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">
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VerifyPage() {
|
||||
return (
|
||||
<main className="mx-auto max-w-rail px-5 py-12">
|
||||
<Suspense fallback={null}>
|
||||
<Verify />
|
||||
</Suspense>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import clsx from "clsx";
|
||||
import Link from "next/link";
|
||||
|
||||
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 buttonClass(variant: Variant = "solid", disabled = false, className?: string) {
|
||||
return 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",
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { Plan } from "@/lib/api";
|
||||
import { limitLabel } from "@/lib/format";
|
||||
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
|
||||
* 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;
|
||||
}) {
|
||||
const rows: { field: string; was: string; now: string }[] = [];
|
||||
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: "support_level",
|
||||
was: plan.support_level || "none",
|
||||
now: next.support_level || "none",
|
||||
});
|
||||
if (plan.base_features.join(",") !== next.base_features.join(","))
|
||||
rows.push({
|
||||
field: "features",
|
||||
was: plan.base_features.join(", ") || "none",
|
||||
now: next.base_features.join(", ") || "none",
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="grid max-w-xl gap-3 rounded border border-warn bg-panel p-5">
|
||||
<h2 className="text-xl">Change what {plan.name} grants?</h2>
|
||||
<ul className="grid gap-1 font-mono text-[0.82rem]">
|
||||
{rows.map((r) => (
|
||||
<li key={r.field} className="flex flex-wrap gap-2">
|
||||
<span className="text-ink-3">{r.field}</span>
|
||||
<span className="text-ink-3 line-through">{r.was}</span>
|
||||
<span className="font-semibold text-ink">→ {r.now}</span>
|
||||
</li>
|
||||
))}
|
||||
{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>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button type="button" onClick={onConfirm}>
|
||||
Change plan
|
||||
</Button>
|
||||
<Button type="button" variant="line" onClick={onCancel}>
|
||||
Keep as is
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Sandbox is hatched as well as coloured, so it survives a colourblind reader
|
||||
* and a glance. It sits in the same place on every screen: issuing against the
|
||||
* wrong environment should feel wrong before you click.
|
||||
*/
|
||||
const ENV = process.env.NEXT_PUBLIC_ADMIN_ENV === "sandbox" ? "sandbox" : "production";
|
||||
|
||||
export function EnvBadge() {
|
||||
const sandbox = ENV === "sandbox";
|
||||
return (
|
||||
<span
|
||||
className={
|
||||
sandbox
|
||||
? "inline-flex items-center gap-2 rounded-sm border border-warn px-2 py-1 font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink [background-image:repeating-linear-gradient(-45deg,var(--accent-wash)_0_6px,transparent_6px_12px)]"
|
||||
: "inline-flex items-center gap-2 rounded-sm bg-accent px-2 py-1 font-mono text-[0.72rem] uppercase tracking-[0.1em] text-accent-ink"
|
||||
}
|
||||
>
|
||||
<i className="h-1.5 w-1.5 shrink-0 rounded-full bg-current" />
|
||||
{sandbox ? "Sandbox" : "Production"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export function Field({
|
||||
label,
|
||||
hint,
|
||||
error,
|
||||
...input
|
||||
}: React.InputHTMLAttributes<HTMLInputElement> & {
|
||||
label: string;
|
||||
hint?: React.ReactNode;
|
||||
error?: string;
|
||||
}) {
|
||||
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}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
"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 { 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 termDays = instance.tier === "free" ? 30 : 365;
|
||||
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 →
|
||||
</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>
|
||||
|
||||
{license && state !== "expired" && (
|
||||
<div className="grid max-w-md gap-1.5">
|
||||
<div className="flex justify-between font-mono text-[0.78rem] tabular-nums text-ink-2">
|
||||
<span>{days} days remaining</span>
|
||||
<span>Renews {formatDate(license.expires_at)}</span>
|
||||
</div>
|
||||
<div className="h-1 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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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" ? (
|
||||
// A paid placeholder (awaiting_link) claims its real install
|
||||
// UUID in place. Anything else without a licence gets one from
|
||||
// the purchase page (self-hosted Free is created there).
|
||||
instance.status === "awaiting_link" ? (
|
||||
<LinkButton href={`/instances/link?claim=${instance.instance_id}`}>Link an install</LinkButton>
|
||||
) : (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import clsx from "clsx";
|
||||
import type { License } from "@/lib/api";
|
||||
import { formatDate, formatStamp, limitLabel } from "@/lib/format";
|
||||
|
||||
const REASON: Record<License["reason"], string> = {
|
||||
new: "New",
|
||||
renewal: "Renewal",
|
||||
tier_change: "Tier change",
|
||||
relink: "Relink",
|
||||
manual: "Manual",
|
||||
};
|
||||
|
||||
/*
|
||||
* 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
|
||||
* 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 (
|
||||
<ul className="grid">
|
||||
{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",
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
{formatStamp(l.issued_at)}
|
||||
</div>
|
||||
<div className="grid justify-items-start gap-1.5">
|
||||
{dead && (
|
||||
<span className="-rotate-2 rounded-sm border-2 border-archival px-1.5 py-0.5 font-mono text-[0.72rem] uppercase tracking-[0.18em] text-archival opacity-75">
|
||||
Superseded
|
||||
</span>
|
||||
)}
|
||||
<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>
|
||||
</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.superseded_by && (
|
||||
<>
|
||||
{" "}
|
||||
· replaced by{" "}
|
||||
<span className="text-accent underline">
|
||||
{l.superseded_by.slice(0, 8)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "./Button";
|
||||
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
export function LicenceDelivery({
|
||||
instanceId,
|
||||
blob,
|
||||
downloadUrl,
|
||||
}: {
|
||||
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);
|
||||
}
|
||||
|
||||
const steps = [
|
||||
<>
|
||||
Open <code className="rounded-sm bg-accent-wash px-1">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.
|
||||
</>,
|
||||
];
|
||||
|
||||
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>
|
||||
</div>
|
||||
<pre className="max-h-48 overflow-y-auto whitespace-pre-wrap break-all rounded border border-dashed border-rule bg-panel-2 p-3 font-mono text-[0.72rem] text-ink-2">
|
||||
{blob}
|
||||
</pre>
|
||||
<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>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
"use client";
|
||||
|
||||
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";
|
||||
|
||||
const ROLES: InstanceRole[] = ["owner", "admin", "member"];
|
||||
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
export function MembersPanel({ instanceId }: { instanceId: string }) {
|
||||
const qc = useQueryClient();
|
||||
const { session } = useSession();
|
||||
const [selected, setSelected] = useState("");
|
||||
const [role, setRole] = useState<InstanceRole>("member");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const members = useQuery({
|
||||
queryKey: ["members", instanceId],
|
||||
queryFn: () => api.members(instanceId),
|
||||
});
|
||||
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 grant = useMutation({
|
||||
mutationFn: () => api.grantMember(instanceId, selected, role),
|
||||
onSuccess: () => {
|
||||
setSelected("");
|
||||
setRole("member");
|
||||
refresh();
|
||||
},
|
||||
onError: fail,
|
||||
});
|
||||
const changeRole = useMutation({
|
||||
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,
|
||||
});
|
||||
|
||||
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 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>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-[0.9rem] text-expired">{error}</p>}
|
||||
|
||||
<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>
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* 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
|
||||
* the browser, or missing from admin's ADMIN_ORIGIN.
|
||||
*/
|
||||
export function NotConnectedPanel({ url }: { url: string }) {
|
||||
return (
|
||||
<div className="grid max-w-2xl gap-3 rounded border border-expired bg-panel p-5">
|
||||
<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.
|
||||
</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.
|
||||
</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’s <code>ADMIN_ORIGIN</code>, or the browser blocks every request.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
"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"
|
||||
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="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"
|
||||
>
|
||||
← {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) => (
|
||||
<span key={f.key} className="flex items-center gap-2">
|
||||
<span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">
|
||||
{f.key}
|
||||
</span>
|
||||
<span className="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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import type { CatalogueRow, Deployment, Plan, Term, Tier } from "@/lib/api";
|
||||
|
||||
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>{key === "console" ? "Browser console" : "Single sign-on"}</span>
|
||||
<span className="text-[0.72rem] text-ink-3">
|
||||
{priced ? "paid add-on" : "included"}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</fieldset>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { queryClient } from "@/lib/query-client";
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import Link from "next/link";
|
||||
import clsx from "clsx";
|
||||
|
||||
const TONE = {
|
||||
expired: "border-l-expired text-expired",
|
||||
warn: "border-l-warn text-warn",
|
||||
accent: "border-l-accent text-accent",
|
||||
} as const;
|
||||
|
||||
export function Queue({
|
||||
title,
|
||||
count,
|
||||
tone,
|
||||
items,
|
||||
}: {
|
||||
title: string;
|
||||
count: number;
|
||||
tone: keyof typeof TONE;
|
||||
items: { label: string; href: string; meta: string }[];
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
className={clsx(
|
||||
"grid gap-2 rounded border border-l-4 border-rule bg-panel p-4",
|
||||
TONE[tone],
|
||||
)}
|
||||
>
|
||||
<h2 className="font-mono text-[0.72rem] font-normal uppercase tracking-[0.1em] text-ink-3">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="text-3xl font-extrabold leading-none tabular-nums tracking-[-0.03em]">
|
||||
{count}
|
||||
</p>
|
||||
{items.length === 0 ? (
|
||||
<p className="text-[0.72rem] text-ink-3">Nothing to do here.</p>
|
||||
) : (
|
||||
<ul className="grid gap-1">
|
||||
{items.map((i) => (
|
||||
<li
|
||||
key={i.href}
|
||||
className="flex justify-between gap-2 font-mono text-[0.72rem] text-ink-2"
|
||||
>
|
||||
<Link href={i.href} className="text-accent underline">
|
||||
{i.label}
|
||||
</Link>
|
||||
<span className="tabular-nums">{i.meta}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "./Button";
|
||||
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;
|
||||
}) {
|
||||
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>
|
||||
{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."
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="line"
|
||||
disabled={exhausted || (open && !UUID_RE.test(value.trim()))}
|
||||
onClick={() => (open ? onRelink(value.trim()) : setOpen(true))}
|
||||
>
|
||||
Relink to a new 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>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import clsx from "clsx";
|
||||
import type { LicenceState } from "@/lib/format";
|
||||
|
||||
const LABEL: Record<LicenceState, string> = {
|
||||
valid: "Valid",
|
||||
warn: "Expiring",
|
||||
expired: "Expired",
|
||||
none: "Awaiting link",
|
||||
};
|
||||
|
||||
/*
|
||||
* State reads three ways: this pill's colour, the pill's SHAPE, and the label.
|
||||
* Colour alone would fail a colourblind reader on the one screen where getting
|
||||
* it wrong costs money.
|
||||
*/
|
||||
const SHAPE: Record<LicenceState, string> = {
|
||||
valid: "rounded-full",
|
||||
warn: "[clip-path:polygon(50%_0,100%_100%,0_100%)]",
|
||||
expired: "[clip-path:polygon(20%_0,80%_0,100%_20%,100%_80%,80%_100%,20%_100%,0_80%,0_20%)]",
|
||||
none: "rounded-none",
|
||||
};
|
||||
|
||||
const TONE: Record<LicenceState, string> = {
|
||||
valid: "border-valid text-valid",
|
||||
warn: "border-warn text-warn",
|
||||
expired: "border-expired text-expired",
|
||||
none: "border-accent text-accent",
|
||||
};
|
||||
|
||||
export function StatePill({ state }: { state: LicenceState }) {
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
"inline-flex shrink-0 items-center gap-1.5 rounded-sm border bg-panel px-2 py-0.5 font-mono text-[0.72rem] uppercase tracking-[0.08em]",
|
||||
TONE[state],
|
||||
)}
|
||||
>
|
||||
<i className={clsx("h-1.5 w-1.5 shrink-0 bg-current", SHAPE[state])} />
|
||||
{LABEL[state]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user