diff --git a/cmd/server/main.go b/cmd/server/main.go index ab88ad9..724dbce 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -845,6 +845,7 @@ func main() { licWrite.POST("/licenses/:id/revoke", adminH.RevokeLicense) licWrite.POST("/licenses/:id/suspend", adminH.SuspendLicense) licWrite.POST("/licenses/:id/reinstate", adminH.ReinstateLicense) + licWrite.POST("/licenses/:id/valid-until", adminH.SetLicenseValidUntil) licWrite.POST("/licenses/:id/change-plan", adminH.ChangeLicensePlan) licWrite.GET("/licenses/:id/usage", adminH.ListLicenseUsage) licWrite.POST("/licenses/:id/usage/reset", adminH.ResetLicenseUsage) diff --git a/docker-compose.yml b/docker-compose.yml index c512247..e9e8537 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,7 +36,10 @@ services: POSTGRES_PASSWORD: keygate POSTGRES_DB: keygate volumes: - - pgdata:/var/lib/postgresql/data + # postgres:18+ images expect the volume mounted at /var/lib/postgresql + # (data lives in a versioned subdirectory); mounting .../data makes + # initdb fail on a fresh volume. https://github.com/docker-library/postgres/issues/37 + - pgdata:/var/lib/postgresql healthcheck: test: ["CMD-SHELL", "pg_isready -U keygate"] interval: 10s diff --git a/internal/handler/admin.go b/internal/handler/admin.go index 968bbad..7e623f2 100644 --- a/internal/handler/admin.go +++ b/internal/handler/admin.go @@ -846,6 +846,10 @@ func (h *AdminHandler) CreateLicense(c *gin.Context) { Notes string `json:"notes"` ExternalCustomerID string `json:"external_customer_id"` ExternalWorkspaceID string `json:"external_workspace_id"` + // ValidUntil sets an explicit expiry (RFC 3339). Empty means the + // plan decides: trial plans get now+trial_days, everything else + // is perpetual. When set it wins over the trial default. + ValidUntil string `json:"valid_until"` } if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "product_id, plan_id, and email are required") @@ -868,6 +872,20 @@ func (h *AdminHandler) CreateLicense(c *gin.Context) { return } + var validUntil *time.Time + if req.ValidUntil != "" { + ts, err := time.Parse(time.RFC3339, req.ValidUntil) + if err != nil { + response.BadRequest(c, "valid_until must be an RFC 3339 timestamp") + return + } + if !ts.After(time.Now()) { + response.BadRequest(c, "valid_until must be in the future") + return + } + validUntil = &ts + } + // Look up plan first to determine license type and set appropriate fields plan, err := h.Store.FindPlanByID(c, req.PlanID) if err != nil { @@ -906,8 +924,11 @@ func (h *AdminHandler) CreateLicense(c *gin.Context) { ExternalWorkspaceID: req.ExternalWorkspaceID, } - // Set valid_until for trial licenses - if plan.LicenseType == "trial" && plan.TrialDays > 0 { + // Set valid_until: an explicit request value wins, otherwise trial + // plans default to now+trial_days and other plans stay perpetual. + if validUntil != nil { + l.ValidUntil = validUntil + } else if plan.LicenseType == "trial" && plan.TrialDays > 0 { until := time.Now().Add(time.Duration(plan.TrialDays) * 24 * time.Hour) l.ValidUntil = &until } @@ -1067,6 +1088,82 @@ func (h *AdminHandler) ReinstateLicense(c *gin.Context) { response.OK(c, gin.H{"status": "active"}) } +// SetLicenseValidUntil sets or clears a license's expiry date. An +// empty valid_until makes the license perpetual. Extending an +// already-expired license does not change its status — use +// /reinstate for that (the two concerns stay separate so an +// accidental date edit can't silently re-arm a revoked customer). +func (h *AdminHandler) SetLicenseValidUntil(c *gin.Context) { + id := c.Param("id") + if !h.checkLicenseScope(c, id) { + return + } + var req struct { + ValidUntil string `json:"valid_until"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "invalid request body") + return + } + + lic, err := h.Store.FindLicenseByID(c, id) + if err != nil { + response.NotFound(c, "license not found") + return + } + + // Stripe owns the expiry on billed licenses — the next renewal + // webhook overwrites whatever we set here, so accepting the edit + // would look like it worked and then silently revert. The dashboard + // hides the control; this closes the same door on the API, which + // licenses:write API keys also reach. + if lic.PaymentProvider == "stripe" { + response.Conflict(c, "STRIPE_MANAGED", + "expiry for Stripe-billed licenses is managed by the subscription", nil) + return + } + + var validUntil *time.Time + if req.ValidUntil != "" { + ts, err := time.Parse(time.RFC3339, req.ValidUntil) + if err != nil { + response.BadRequest(c, "valid_until must be an RFC 3339 timestamp or empty") + return + } + // Same rule as CreateLicense. Back-dating is not an "expire now" + // shortcut: the grace-expiry sweep would pick the license up and + // email the customer that it expired, so a mistyped year turns + // into customer-facing mail. Use revoke/suspend to end a license. + if !ts.After(time.Now()) { + response.BadRequest(c, "valid_until must be in the future") + return + } + validUntil = &ts + } + + lic.ValidUntil = validUntil + if err := h.Store.UpdateLicense(c, lic, "valid_until"); err != nil { + response.Internal(c) + return + } + + h.Store.Audit(c, &model.AuditLog{ + Entity: "license", EntityID: id, Action: "valid_until_changed", + ActorType: "admin", ActorID: adminID(c), + Changes: map[string]any{"valid_until": req.ValidUntil}, + }) + if h.Webhook != nil { + // null valid_until means perpetual — send it explicitly so an + // integration can tell "cleared" apart from "field omitted". + payload := map[string]any{"license_id": id, "email": lic.Email, "valid_until": nil} + if validUntil != nil { + payload["valid_until"] = validUntil.Format(time.RFC3339) + } + h.Webhook.Dispatch(c, lic.ProductID, "license.expiry_changed", payload) + } + response.OK(c, lic) +} + func (h *AdminHandler) DeleteActivation(c *gin.Context) { id := c.Param("id") pid, err := h.Store.GetActivationProductID(c, id) diff --git a/internal/handler/setup.go b/internal/handler/setup.go index ac032bd..cd8cebd 100644 --- a/internal/handler/setup.go +++ b/internal/handler/setup.go @@ -173,8 +173,8 @@ func (h *SetupHandler) Initialize(c *gin.Context) { maxActivations, maxSeats = 3, 10 } if _, err := tx.NewRaw( - "INSERT INTO plans (id, product_id, name, slug, license_type, max_activations, max_seats, grace_days, active, created_at) VALUES (?, ?, 'Pro', 'pro', 'subscription', ?, ?, 7, true, now())", - planID, productID, maxActivations, maxSeats, + "INSERT INTO plans (id, product_id, name, slug, license_type, max_activations, max_seats, grace_days, active, checkout_id, created_at) VALUES (?, ?, 'Pro', 'pro', 'subscription', ?, ?, 7, true, ?, now())", + planID, productID, maxActivations, maxSeats, store.ShortID(), ).Exec(ctx); err != nil { response.Internal(c) return diff --git a/internal/store/admin.go b/internal/store/admin.go index 9d84646..ff2b4fa 100644 --- a/internal/store/admin.go +++ b/internal/store/admin.go @@ -78,6 +78,10 @@ func shortID() string { return hex.EncodeToString(b)[:8] } +// ShortID generates a URL-safe 8-character unique ID for checkout links. +// Exported for use by the setup handler. +func ShortID() string { return shortID() } + func (s *Store) UpdatePlan(ctx context.Context, p *model.Plan) error { _, err := s.DB.NewUpdate().Model(p).WherePK().Exec(ctx) return err diff --git a/web/src/hooks/use-site-config.tsx b/web/src/hooks/use-site-config.tsx index 091a98b..4611887 100644 --- a/web/src/hooks/use-site-config.tsx +++ b/web/src/hooks/use-site-config.tsx @@ -42,10 +42,15 @@ export function SiteConfigProvider({ children }: { children: ReactNode }) { attribution_url: data.attribution_url || "https://keygate.app", loading: false, }) - // Dynamic favicon from custom logo + // Dynamic favicon from custom logo. index.html declares + // multiple variants and browsers pick their + // favorite (often the sizes="32x32" one), so rewriting only + // the first link never visibly changed the tab icon — update + // them all. if (data.logo_url) { - const link = document.querySelector("link[rel='icon']") as HTMLLinkElement - if (link) link.href = data.logo_url + document.querySelectorAll("link[rel~='icon']").forEach((link) => { + link.href = data.logo_url + }) } if (data.brand_color) { document.documentElement.style.setProperty("--color-primary", data.brand_color) diff --git a/web/src/i18n/locales/en.ts b/web/src/i18n/locales/en.ts index 24b984f..422b1ba 100644 --- a/web/src/i18n/locales/en.ts +++ b/web/src/i18n/locales/en.ts @@ -125,6 +125,13 @@ const en = { "licenses.issue": "Issue License", "licenses.licenseKey": "License Key", "licenses.validUntil": "Valid Until", + "licenses.perpetual": "Perpetual", + "licenses.validUntilOptional": "Valid until (optional)", + "licenses.validUntilHint": "Leave empty for a perpetual license (trials still use the plan's trial days).", + "licenses.validUntilEdit": "Edit expiry", + "licenses.validUntilClear": "Leave empty to make the license perpetual.", + "licenses.validUntilExpiredHint": + "This license is expired — a new date alone won't reactivate it. Use Reinstate as well.", "licenses.empty": "No licenses found", "licenses.noProducts": "No products yet", "licenses.noProductsDesc": "Create a product and plan first before issuing licenses.", diff --git a/web/src/i18n/locales/zh.ts b/web/src/i18n/locales/zh.ts index 6bdbb1a..d69af72 100644 --- a/web/src/i18n/locales/zh.ts +++ b/web/src/i18n/locales/zh.ts @@ -123,6 +123,12 @@ const zh = { "licenses.issue": "签发许可证", "licenses.licenseKey": "许可证密钥", "licenses.validUntil": "有效期至", + "licenses.perpetual": "永久", + "licenses.validUntilOptional": "有效期至(可选)", + "licenses.validUntilHint": "留空表示永久许可证(试用版仍按套餐的试用天数计算)。", + "licenses.validUntilEdit": "编辑到期时间", + "licenses.validUntilClear": "留空表示永久许可证。", + "licenses.validUntilExpiredHint": "此许可证已过期 — 仅修改日期不会重新激活,还需执行「恢复」。", "licenses.empty": "未找到许可证", "licenses.noProducts": "暂无产品", "licenses.noProductsDesc": "请先创建产品和方案,然后再签发许可证。", diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 50c6229..85b4866 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -202,7 +202,11 @@ export const admin = { notes?: string external_customer_id?: string external_workspace_id?: string + valid_until?: string }) => post("/admin/licenses", data), + // Empty valid_until clears the expiry (perpetual license). + setLicenseValidUntil: (id: string, validUntil: string) => + post(`/admin/licenses/${id}/valid-until`, { valid_until: validUntil }), revokeLicense: (id: string) => post(`/admin/licenses/${id}/revoke`), suspendLicense: (id: string) => post(`/admin/licenses/${id}/suspend`), reinstateLicense: (id: string) => post(`/admin/licenses/${id}/reinstate`), diff --git a/web/src/pages/admin/licenses.tsx b/web/src/pages/admin/licenses.tsx index 42b30a6..35ee54c 100644 --- a/web/src/pages/admin/licenses.tsx +++ b/web/src/pages/admin/licenses.tsx @@ -1,5 +1,5 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" -import { Ban, Check, Copy, Eye, Package, Pause, Play, Plus, RefreshCw, Search, Trash2 } from "lucide-react" +import { Ban, Check, Copy, Eye, Package, Pause, Pencil, Play, Plus, RefreshCw, Search, Trash2 } from "lucide-react" import { useState } from "react" import { Link } from "react-router-dom" import { showToast } from "@/components/toast" @@ -36,6 +36,24 @@ import { useI18n } from "@/i18n" import { admin } from "@/lib/api" import { formatDate, statusColor } from "@/lib/utils" +// A date picked in the expiry field means "valid through that whole +// day" in the admin's local timezone, so the license dies at local +// 23:59:59 rather than end-of-day UTC (which renders as an odd +// mid-evening time for anyone west of Greenwich). +function endOfDayISO(date: string): string { + const [y, m, d] = date.split("-").map(Number) + return new Date(y, m - 1, d, 23, 59, 59).toISOString() +} + +// Inverse of endOfDayISO for prefilling the date input: the stored +// instant rendered as a local YYYY-MM-DD (slicing the UTC string +// would land on the next day for anyone west of Greenwich). +function localDateValue(iso: string): string { + const d = new Date(iso) + const pad = (n: number) => String(n).padStart(2, "0") + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +} + export default function LicensesPage() { const { t } = useI18n() const qc = useQueryClient() @@ -198,7 +216,7 @@ export default function LicensesPage() { {t(`status.${lic.status}` as any)} - {formatDate(lic.valid_until)} + {lic.valid_until ? formatDate(lic.valid_until) : t("licenses.perpetual")} {formatDate(lic.created_at)} @@ -260,6 +278,7 @@ function CreateLicenseDialog({ notes?: string external_customer_id?: string external_workspace_id?: string + valid_until?: string }) => void loading: boolean }) { @@ -270,6 +289,7 @@ function CreateLicenseDialog({ const [planId, setPlanId] = useState("") const [externalCustomerID, setExternalCustomerID] = useState("") const [externalWorkspaceID, setExternalWorkspaceID] = useState("") + const [validUntil, setValidUntil] = useState("") const { data: plansData } = useQuery({ queryKey: ["admin", "plans", productId], @@ -310,6 +330,7 @@ function CreateLicenseDialog({ notes, external_customer_id: externalCustomerID.trim() || undefined, external_workspace_id: externalWorkspaceID.trim() || undefined, + valid_until: validUntil ? endOfDayISO(validUntil) : undefined, }) }} className="space-y-4" @@ -360,6 +381,16 @@ function CreateLicenseDialog({ setNotes(e.target.value)} /> +
+ + setValidUntil(e.target.value)} + /> +

{t("licenses.validUntilHint")}

+
{/* External identifiers — opaque strings the merchant uses to map their own user/workspace model to this license. Both optional; leave blank if not integrating with an @@ -404,6 +435,8 @@ function LicenseDetail({ id, onClose }: { id: string; onClose: () => void }) { const { data: lic, isLoading } = useQuery({ queryKey: ["admin", "license", id], queryFn: () => admin.getLicense(id) }) const [copied, setCopied] = useState(false) const [changingPlan, setChangingPlan] = useState(false) + // null = not editing; "" = editing with empty value (perpetual) + const [editingValidUntil, setEditingValidUntil] = useState(null) const revokeMut = useMutation({ mutationFn: () => admin.revokeLicense(id), @@ -429,6 +462,13 @@ function LicenseDetail({ id, onClose }: { id: string; onClose: () => void }) { qc.invalidateQueries({ queryKey: ["admin"] }) }, }) + const validUntilMut = useMutation({ + mutationFn: (validUntil: string) => admin.setLicenseValidUntil(id, validUntil), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["admin"] }) + setEditingValidUntil(null) + }, + }) // Activation deletion is destructive — wrap in a confirmation // state so a stray ghost-click on the trash icon (icons sit // close together in the row) doesn't immediately revoke a device. @@ -497,7 +537,56 @@ function LicenseDetail({ id, onClose }: { id: string; onClose: () => void }) {

{t("licenses.validUntil")}

-

{formatDate(lic.valid_until)}

+ {editingValidUntil === null ? ( +
+

{lic.valid_until ? formatDate(lic.valid_until) : t("licenses.perpetual")}

+ {/* Stripe owns the expiry for subscription licenses — + editing it here would be overwritten on renewal. */} + {lic.payment_provider !== "stripe" && ( + + )} +
+ ) : ( +
+
+ setEditingValidUntil(e.target.value)} + /> + + +
+

{t("licenses.validUntilClear")}

+ {/* An expired license stays dead no matter what date + is set — assertUsable short-circuits on the status + before it ever reads valid_until. Say so, or the + admin walks away thinking the edit revived it. */} + {lic.status === "expired" && ( +

{t("licenses.validUntilExpiredHint")}

+ )} +
+ )}
{lic.payment_provider && (
diff --git a/web/src/pages/admin/webhooks.tsx b/web/src/pages/admin/webhooks.tsx index 8f9c9c6..bacb80e 100644 --- a/web/src/pages/admin/webhooks.tsx +++ b/web/src/pages/admin/webhooks.tsx @@ -46,12 +46,22 @@ import { useI18n } from "@/i18n" import { admin, type WebhookConfig } from "@/lib/api" import { boolColor, formatDate } from "@/lib/utils" +// Must stay in sync with the events the backend actually dispatches — +// Dispatch only delivers to webhooks subscribed to the event, so an +// event missing from this list can never be subscribed to and is +// effectively undeliverable for anyone configuring from the dashboard. const WEBHOOK_EVENTS = [ "license.created", + "license.activated", + "license.deactivated", + "license.expiry_changed", + "license.expired", "license.canceled", "license.suspended", "license.reinstated", "license.revoked", + "license.payment_failed", + "license.payment_recovered", "quota.warning", "quota.exceeded", "seat.added",