Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 4 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
101 changes: 99 additions & 2 deletions internal/handler/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions internal/handler/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions internal/store/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 8 additions & 3 deletions web/src/hooks/use-site-config.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <link rel="icon"> 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<HTMLLinkElement>("link[rel~='icon']").forEach((link) => {
link.href = data.logo_url
})
}
if (data.brand_color) {
document.documentElement.style.setProperty("--color-primary", data.brand_color)
Expand Down
7 changes: 7 additions & 0 deletions web/src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
6 changes: 6 additions & 0 deletions web/src/i18n/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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": "请先创建产品和方案,然后再签发许可证。",
Expand Down
4 changes: 4 additions & 0 deletions web/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,11 @@ export const admin = {
notes?: string
external_customer_id?: string
external_workspace_id?: string
valid_until?: string
}) => post<License>("/admin/licenses", data),
// Empty valid_until clears the expiry (perpetual license).
setLicenseValidUntil: (id: string, validUntil: string) =>
post<License>(`/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`),
Expand Down
95 changes: 92 additions & 3 deletions web/src/pages/admin/licenses.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -198,7 +216,7 @@ export default function LicensesPage() {
<Badge className={statusColor(lic.status)}>{t(`status.${lic.status}` as any)}</Badge>
</DataTableCell>
<DataTableCell className="text-muted-foreground text-xs">
{formatDate(lic.valid_until)}
{lic.valid_until ? formatDate(lic.valid_until) : t("licenses.perpetual")}
</DataTableCell>
<DataTableCell className="text-muted-foreground text-xs">
{formatDate(lic.created_at)}
Expand Down Expand Up @@ -260,6 +278,7 @@ function CreateLicenseDialog({
notes?: string
external_customer_id?: string
external_workspace_id?: string
valid_until?: string
}) => void
loading: boolean
}) {
Expand All @@ -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],
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -360,6 +381,16 @@ function CreateLicenseDialog({
<Label>{t("licenses.notesOptional")}</Label>
<Input value={notes} onChange={(e) => setNotes(e.target.value)} />
</div>
<div className="space-y-2">
<Label>{t("licenses.validUntilOptional")}</Label>
<Input
type="date"
value={validUntil}
min={new Date().toISOString().slice(0, 10)}
onChange={(e) => setValidUntil(e.target.value)}
/>
<p className="text-xs text-muted-foreground">{t("licenses.validUntilHint")}</p>
</div>
{/* 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
Expand Down Expand Up @@ -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<string | null>(null)

const revokeMut = useMutation({
mutationFn: () => admin.revokeLicense(id),
Expand All @@ -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.
Expand Down Expand Up @@ -497,7 +537,56 @@ function LicenseDetail({ id, onClose }: { id: string; onClose: () => void }) {
</div>
<div>
<p className="text-muted-foreground">{t("licenses.validUntil")}</p>
<p className="mt-1">{formatDate(lic.valid_until)}</p>
{editingValidUntil === null ? (
<div className="flex items-center gap-1 mt-1">
<p>{lic.valid_until ? formatDate(lic.valid_until) : t("licenses.perpetual")}</p>
{/* Stripe owns the expiry for subscription licenses —
editing it here would be overwritten on renewal. */}
{lic.payment_provider !== "stripe" && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
title={t("licenses.validUntilEdit")}
onClick={() => setEditingValidUntil(lic.valid_until ? localDateValue(lic.valid_until) : "")}
>
<Pencil className="h-3 w-3" />
</Button>
)}
</div>
) : (
<div className="mt-1 space-y-1">
<div className="flex items-center gap-1">
<Input
type="date"
className="h-7 w-40 text-xs"
value={editingValidUntil}
onChange={(e) => setEditingValidUntil(e.target.value)}
/>
<Button
size="sm"
className="h-7"
disabled={validUntilMut.isPending}
onClick={() =>
validUntilMut.mutate(editingValidUntil ? endOfDayISO(editingValidUntil) : "")
}
>
{t("common.save")}
</Button>
<Button size="sm" variant="ghost" className="h-7" onClick={() => setEditingValidUntil(null)}>
{t("common.cancel")}
</Button>
</div>
<p className="text-xs text-muted-foreground">{t("licenses.validUntilClear")}</p>
{/* 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" && (
<p className="text-xs text-amber-600">{t("licenses.validUntilExpiredHint")}</p>
)}
</div>
)}
</div>
{lic.payment_provider && (
<div>
Expand Down
Loading