feat: offline license/trial system

7-day full-access trial, then gated behind an Ed25519-signed license key
(internal/license) verified entirely offline — no phone-home dependency.
Middleware 402s non-exempt API routes once the trial lapses with no valid
key; frontend shows a countdown banner in the last 3 days and a blocking
overlay once actually gated, both pointing at the new Settings -> Лицензия
tab. tools/gen-license mints keys for the vendor side (never ships the
private key).
This commit is contained in:
root
2026-08-21 18:44:54 +00:00
parent e8d037bef3
commit 56ffca767d
16 changed files with 731 additions and 48 deletions
+9
View File
@@ -31,6 +31,7 @@ import (
"production/internal/imei"
"production/internal/inventory"
"production/internal/kkm/kkmserver"
"production/internal/license"
"production/internal/loyalty"
"production/internal/manufacture"
"production/internal/maxbot"
@@ -237,6 +238,11 @@ func main() {
// /api/module-control/* routes (see internal/modulecontrol's doc
// comment on why a maintenance flag must never block its own toggle).
app.Use(moduleControlHandler.Middleware())
licenseSource := func() (string, time.Time, error) {
s, err := settings.Fetch(context.Background(), pgPool)
return s.LicenseKey, s.TrialStartedAt, err
}
app.Use(license.Middleware(licenseSource))
app.Get("/health", func(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"ok": true})
@@ -645,6 +651,9 @@ func main() {
// settingsPerm — see the comment on its declaration above; still the
// most sensitive gate, holds live API keys/bot tokens.
// Open to any staff role (not settingsPerm) — the trial countdown banner
// is for everyone, not just the owner who manages the rest of Settings.
api.Get("/license/status", staffAuth, staffLimit, license.StatusHandler(licenseSource))
api.Get("/settings", staffAuth, staffLimit, settingsPerm, settingsHandler.Get)
api.Put("/settings", staffAuth, staffLimit, settingsPerm, settingsHandler.Update)
@@ -0,0 +1,85 @@
// Package license verifies purchased Aura CRM licenses entirely offline —
// no server to phone home to, no crash loop if that server is ever
// unreachable (see this repo's own history for exactly that failure mode
// in a different product this business runs). A license key is a JSON
// payload signed with an Ed25519 private key the vendor keeps offline
// (see tools/gen-license); this package only ever holds the matching
// PUBLIC key, so shipping this source (or the compiled binary) to a
// customer can never leak the ability to mint new licenses — verifying and
// signing use different keys, unlike an HMAC scheme where they'd be the
// same secret.
package license
import (
"crypto/ed25519"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"strings"
"time"
)
// publicKeyHex is safe to be public — see package doc comment. Generated
// once per vendor; changing it invalidates every license issued under the
// old key.
const publicKeyHex = "e1f8a79b84ad09cf357ae0548fd2bb77184a33eca9516dee67903b784245e99f"
var ErrInvalidSignature = errors.New("license: invalid signature")
var ErrMalformed = errors.New("license: malformed key")
// Payload is what a license key actually asserts. ExpiresAt nil means a
// perpetual license (paid in full, not a subscription) — Valid() treats
// that as never expiring.
type Payload struct {
Customer string `json:"customer"`
IssuedAt time.Time `json:"issued_at"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
// Valid reports whether this payload's own terms are currently satisfied —
// it does NOT re-verify the signature (Parse already did that; a Payload
// only ever exists after a successful Parse).
func (p *Payload) Valid() bool {
return p.ExpiresAt == nil || p.ExpiresAt.After(time.Now())
}
// Parse verifies a license key's Ed25519 signature and decodes its
// payload. Format: base64(payload-json) + "." + base64(signature-over-the-
// undecoded-payload-b64-string) — signing the base64 text rather than the
// raw JSON bytes means there's exactly one canonical byte sequence to sign
// and verify, no risk of a JSON re-serialization producing different bytes
// on the two sides.
func Parse(key string) (*Payload, error) {
key = strings.TrimSpace(key)
parts := strings.SplitN(key, ".", 2)
if len(parts) != 2 {
return nil, ErrMalformed
}
payloadB64, sigB64 := parts[0], parts[1]
sig, err := base64.RawURLEncoding.DecodeString(sigB64)
if err != nil {
return nil, ErrMalformed
}
pubKey, err := hex.DecodeString(publicKeyHex)
if err != nil {
// Only possible if publicKeyHex itself was edited to something
// invalid — a build-time bug, not a runtime/user-input error.
return nil, errors.New("license: invalid embedded public key")
}
if !ed25519.Verify(ed25519.PublicKey(pubKey), []byte(payloadB64), sig) {
return nil, ErrInvalidSignature
}
payloadJSON, err := base64.RawURLEncoding.DecodeString(payloadB64)
if err != nil {
return nil, ErrMalformed
}
var p Payload
if err := json.Unmarshal(payloadJSON, &p); err != nil {
return nil, ErrMalformed
}
return &p, nil
}
@@ -0,0 +1,63 @@
package license
import (
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"testing"
"time"
)
// testKeyPair generates a fresh Ed25519 pair and signs payload with the
// resulting private key — Parse itself always verifies against the
// package's real embedded publicKeyHex, so these helpers exist only to
// exercise the malformed/tampered paths that don't need a genuine
// signature to fail correctly.
func signWithKey(priv ed25519.PrivateKey, p Payload) string {
payloadJSON, _ := json.Marshal(p)
payloadB64 := base64.RawURLEncoding.EncodeToString(payloadJSON)
sig := ed25519.Sign(priv, []byte(payloadB64))
sigB64 := base64.RawURLEncoding.EncodeToString(sig)
return payloadB64 + "." + sigB64
}
func TestParseRejectsSignatureFromWrongKey(t *testing.T) {
_, wrongPriv, _ := ed25519.GenerateKey(nil)
key := signWithKey(wrongPriv, Payload{Customer: "Someone"})
_, err := Parse(key)
if err != ErrInvalidSignature {
t.Errorf("Parse() err = %v, want ErrInvalidSignature", err)
}
}
func TestParseRejectsMalformedInput(t *testing.T) {
cases := []string{"", "no-dot-here", "not-base64.also-not-base64", "..", "a.b.c"}
for _, c := range cases {
if _, err := Parse(c); err == nil {
t.Errorf("Parse(%q) = nil error, want an error", c)
}
}
}
func TestPayloadValidPerpetualNeverExpires(t *testing.T) {
p := Payload{Customer: "X", IssuedAt: time.Now()}
if !p.Valid() {
t.Error("Valid() = false for a nil-ExpiresAt (perpetual) payload, want true")
}
}
func TestPayloadValidRespectsExpiry(t *testing.T) {
past := time.Now().Add(-time.Hour)
future := time.Now().Add(time.Hour)
expired := Payload{ExpiresAt: &past}
if expired.Valid() {
t.Error("Valid() = true for an already-expired payload, want false")
}
current := Payload{ExpiresAt: &future}
if !current.Valid() {
t.Error("Valid() = false for a not-yet-expired payload, want true")
}
}
@@ -0,0 +1,84 @@
package license
import (
"strings"
"time"
"github.com/gofiber/fiber/v2"
)
// exemptPrefixes stay reachable even when the trial has run out with no
// valid license: /settings so an owner can actually paste a key in to
// unlock everything else, and everything already public/unauthenticated
// (client order tracking, inbound webhooks, core's maintenance control) —
// gating those too would mean a shop's existing customers lose their
// tracking link, and core losing the ability to flip this module into
// maintenance mode, over a licensing lapse that has nothing to do with them.
var exemptPrefixes = []string{
"/api/settings",
"/api/license/status",
"/api/public/",
"/api/webhooks/",
"/api/track",
"/api/module-control/",
}
// Source reads the two values Check needs. Deliberately not a direct
// dependency on internal/settings — that package needs license.Parse (to
// validate a key at save time), and license needing settings back would be
// an import cycle. main.go wires the real settings.Fetch in as this
// closure; that's the only place both packages need to be known at once.
type Source func() (licenseKey string, trialStartedAt time.Time, err error)
// Middleware gates the rest of the API — the actual CRM functionality —
// behind Check(...).Allowed(). Register it before the route groups so it
// runs on every request (same pattern as internal/modulecontrol's own
// global middleware).
func Middleware(source Source) fiber.Handler {
return func(c *fiber.Ctx) error {
path := c.Path()
for _, prefix := range exemptPrefixes {
if strings.HasPrefix(path, prefix) {
return c.Next()
}
}
licenseKey, trialStartedAt, err := source()
if err != nil {
// Fail open on our own DB read failing — that's a different,
// already-loud problem (nothing works without settings/DB
// anyway); it must not also masquerade as a licensing lockout.
return c.Next()
}
if Check(licenseKey, trialStartedAt).Allowed() {
return c.Next()
}
return c.Status(402).JSON(fiber.Map{
"error": "trial_expired",
"message": "Пробный период закончился. Введите лицензионный ключ в Настройках, чтобы продолжить работу.",
})
}
}
// StatusHandler is a small standalone endpoint (not gated by
// settingsPerm — every staff member sees the trial countdown, not just the
// owner who manages the rest of Settings) so the frontend can show a
// banner without fetching the full Settings payload. Deliberately excludes
// Payload — a customer name isn't something every staff login needs to see.
func StatusHandler(source Source) fiber.Handler {
return func(c *fiber.Ctx) error {
licenseKey, trialStartedAt, err := source()
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
status := Check(licenseKey, trialStartedAt)
return c.JSON(fiber.Map{
"licensed": status.Licensed,
"trial_active": status.TrialActive,
"trial_days_left": status.TrialDaysLeft,
"allowed": status.Allowed(),
})
}
}
@@ -0,0 +1,55 @@
package license
import "time"
// TrialDuration is deliberately not configurable — an owner who could
// extend their own trial from Settings would defeat the point of having
// one. Change this constant and rebuild if the vendor ever wants a
// different trial length for a new release.
const TrialDuration = 7 * 24 * time.Hour
// Status is the outcome of checking a license key + trial window together —
// exactly what a request-gating middleware and the Settings UI both need,
// computed once so they can't disagree with each other.
type Status struct {
Licensed bool
TrialActive bool
TrialDaysLeft int
Payload *Payload // nil unless Licensed
}
// Allowed is the actual gate condition: a valid license OR still inside the
// trial window. Either one is sufficient.
func (s Status) Allowed() bool {
return s.Licensed || s.TrialActive
}
// Check combines a (possibly empty/invalid) stored license key with the
// trial start timestamp into one Status. Never returns an error — an
// invalid/expired/malformed key just means Licensed=false, falling back to
// whatever the trial window says, same as no key at all.
func Check(licenseKey string, trialStartedAt time.Time) Status {
var s Status
if licenseKey != "" {
if p, err := Parse(licenseKey); err == nil && p.Valid() {
s.Licensed = true
s.Payload = p
}
}
trialEnd := trialStartedAt.Add(TrialDuration)
if remaining := time.Until(trialEnd); remaining > 0 {
s.TrialActive = true
// Ceiling division so "23 hours left" still reads as 1 day, not 0 —
// an owner watching the countdown hit zero without a key entered
// should never see it jump straight from 1 to a hard lock with no
// "0 days left, expires today" warning in between.
s.TrialDaysLeft = int(remaining / (24 * time.Hour))
if remaining%(24*time.Hour) > 0 {
s.TrialDaysLeft++
}
}
return s
}
@@ -0,0 +1,62 @@
package license
import (
"testing"
"time"
)
func TestCheckNoKeyWithinTrialWindow(t *testing.T) {
s := Check("", time.Now().Add(-2*24*time.Hour))
if s.Licensed {
t.Error("Licensed = true with no key, want false")
}
if !s.TrialActive {
t.Error("TrialActive = false 2 days into a 7-day trial, want true")
}
if !s.Allowed() {
t.Error("Allowed() = false during active trial, want true")
}
}
func TestCheckNoKeyTrialExpired(t *testing.T) {
s := Check("", time.Now().Add(-8*24*time.Hour))
if s.TrialActive {
t.Error("TrialActive = true 8 days into a 7-day trial, want false")
}
if s.Allowed() {
t.Error("Allowed() = true after trial expiry with no license, want false")
}
}
func TestCheckInvalidKeyFallsBackToTrial(t *testing.T) {
s := Check("garbage-not-a-license-key", time.Now())
if s.Licensed {
t.Error("Licensed = true for an unparseable key, want false")
}
if !s.Allowed() {
t.Error("Allowed() = false with an invalid key but fresh trial, want true (trial covers it)")
}
}
func TestCheckTrialDaysLeftRoundsUp(t *testing.T) {
// 23 hours remaining should still read as 1 day left, not 0 — see
// status.go's own comment on why ceiling division matters here.
trialStart := time.Now().Add(-(TrialDuration - 23*time.Hour))
s := Check("", trialStart)
if s.TrialDaysLeft != 1 {
t.Errorf("TrialDaysLeft = %d with 23h remaining, want 1", s.TrialDaysLeft)
}
}
func TestCheckTrialDaysLeftExactDays(t *testing.T) {
trialStart := time.Now().Add(-(TrialDuration - 3*24*time.Hour))
s := Check("", trialStart)
if s.TrialDaysLeft != 3 {
t.Errorf("TrialDaysLeft = %d with exactly 3 days remaining, want 3", s.TrialDaysLeft)
}
}
@@ -9,6 +9,7 @@ import (
"strconv"
"production/internal/auth"
"production/internal/license"
"github.com/gofiber/fiber/v2"
"github.com/jackc/pgx/v5/pgxpool"
@@ -183,6 +184,7 @@ type updateInput struct {
ClientVkCommunityID *string `json:"client_vk_community_id"`
DiaxProEmail *string `json:"diaxpro_email"`
DiaxProPassword *string `json:"diaxpro_password"`
LicenseKey *string `json:"license_key"`
}
// Update is a partial update — every field is optional and COALESCEd
@@ -233,6 +235,16 @@ func (h *Handler) Update(c *fiber.Ctx) error {
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
}
}
// Reject an unparseable/forged key at save time — immediate feedback in
// the UI beats silently storing a string that will just fail the same
// check on every request from then on. An empty string clears the key
// (same COALESCE convention as every other nullable field here) and is
// never sent through Parse.
if body.LicenseKey != nil && *body.LicenseKey != "" {
if _, err := license.Parse(*body.LicenseKey); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "invalid license key"})
}
}
_, err := h.db.Exec(context.Background(), `
UPDATE settings SET
@@ -280,8 +292,9 @@ func (h *Handler) Update(c *fiber.Ctx) error {
client_vk_community_id = COALESCE($42, client_vk_community_id),
diaxpro_email = COALESCE($43, diaxpro_email),
diaxpro_password = COALESCE($44, diaxpro_password),
license_key = COALESCE($45, license_key),
updated_at = NOW(),
updated_by_staff_name = $45
updated_by_staff_name = $46
WHERE id = 1`,
body.BusinessName, body.BusinessINN, body.BusinessKPP, body.BusinessAddress, body.BusinessPhone,
body.BusinessBankName, body.BusinessBankAccount, body.BusinessBankBIK, body.BusinessBankCorrAccount,
@@ -294,6 +307,7 @@ func (h *Handler) Update(c *fiber.Ctx) error {
body.MaxBotToken, body.MaxWebhookSecret, body.ClientMaxBotUsername,
body.VkGroupToken, body.VkGroupID, body.VkSecretKey, body.VkConfirmationCode, body.ClientVkCommunityID,
body.DiaxProEmail, body.DiaxProPassword,
body.LicenseKey,
auth.StaffName(c),
)
if err != nil {
@@ -76,6 +76,13 @@ type Settings struct {
ClientVkCommunityID string `json:"client_vk_community_id"`
DiaxProEmail string `json:"diaxpro_email"`
DiaxProPassword string `json:"diaxpro_password"`
// TrialStartedAt is stamped once by migration 067 the moment this
// database is first created — see that migration's doc comment for why
// that's a reliable first-boot timestamp with no separate detection
// code. LicenseKey is empty until an owner pastes one into Settings;
// see internal/license for verification.
TrialStartedAt time.Time `json:"trial_started_at"`
LicenseKey string `json:"license_key"`
UpdatedAt *time.Time `json:"updated_at"`
UpdatedByStaffName string `json:"updated_by_staff_name"`
}
@@ -109,6 +116,7 @@ func Fetch(ctx context.Context, db *pgxpool.Pool) (Settings, error) {
COALESCE(vk_group_token, ''), COALESCE(vk_group_id, ''), COALESCE(vk_secret_key, ''),
COALESCE(vk_confirmation_code, ''), COALESCE(client_vk_community_id, ''),
COALESCE(diaxpro_email, ''), COALESCE(diaxpro_password, ''),
trial_started_at, COALESCE(license_key, ''),
updated_at, COALESCE(updated_by_staff_name, '')
FROM settings WHERE id = 1`,
).Scan(
@@ -124,6 +132,7 @@ func Fetch(ctx context.Context, db *pgxpool.Pool) (Settings, error) {
&s.MaxBotToken, &s.MaxWebhookSecret, &s.ClientMaxBotUsername,
&s.VkGroupToken, &s.VkGroupID, &s.VkSecretKey, &s.VkConfirmationCode, &s.ClientVkCommunityID,
&s.DiaxProEmail, &s.DiaxProPassword,
&s.TrialStartedAt, &s.LicenseKey,
&s.UpdatedAt, &s.UpdatedByStaffName,
)
if err != nil {
@@ -0,0 +1,17 @@
-- +goose Up
-- trial_started_at is stamped once, right here, at the moment this
-- migration first runs on a fresh install — i.e. the very first time this
-- app's database is created. That's what makes it a reliable "first boot"
-- timestamp without any separate first-run-detection code: goose only ever
-- runs this migration once per database, so DEFAULT NOW() here IS the
-- trial clock starting. license_key is NULL until an owner pastes one into
-- Settings; see internal/license for verification and internal/license/
-- middleware.go for what happens once the trial window closes with no
-- valid key.
ALTER TABLE settings ADD COLUMN trial_started_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
ALTER TABLE settings ADD COLUMN license_key TEXT;
-- +goose Down
ALTER TABLE settings DROP COLUMN trial_started_at;
ALTER TABLE settings DROP COLUMN license_key;
@@ -0,0 +1,92 @@
// Command gen-license mints signed license keys for Aura CRM (see
// internal/license's package doc for the format/threat model). This is a
// vendor-side tool — it needs the Ed25519 PRIVATE key, which must never
// ship inside the product itself or this repo. Run it only on a machine
// that holds that key, e.g.:
//
// go run ./tools/gen-license -customer "ООО Ромашка" -days 365 \
// -key /root/.aura-license/private_key.hex
package main
import (
"crypto/ed25519"
"encoding/base64"
"encoding/hex"
"encoding/json"
"flag"
"fmt"
"os"
"strings"
"time"
)
// Mirrors internal/license.Payload field-for-field — duplicated rather
// than imported so this tool has zero dependency on the rest of the
// module and can be copy-pasted out to a vendor-only machine on its own.
type payload struct {
Customer string `json:"customer"`
IssuedAt time.Time `json:"issued_at"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
func main() {
customer := flag.String("customer", "", "customer/business name this license is issued to (required)")
days := flag.Int("days", 0, "days until expiry from now; 0 = perpetual license")
keyPath := flag.String("key", "", "path to the hex-encoded Ed25519 private key (required)")
flag.Parse()
if *customer == "" || *keyPath == "" {
fmt.Fprintln(os.Stderr, "usage: gen-license -customer NAME -key PATH [-days N]")
os.Exit(1)
}
priv, err := loadPrivateKey(*keyPath)
if err != nil {
fmt.Fprintf(os.Stderr, "gen-license: %v\n", err)
os.Exit(1)
}
p := payload{Customer: *customer, IssuedAt: time.Now().UTC()}
if *days > 0 {
exp := p.IssuedAt.AddDate(0, 0, *days)
p.ExpiresAt = &exp
}
key, err := sign(priv, p)
if err != nil {
fmt.Fprintf(os.Stderr, "gen-license: %v\n", err)
os.Exit(1)
}
fmt.Println(key)
}
func loadPrivateKey(path string) (ed25519.PrivateKey, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading key file: %w", err)
}
decoded, err := hex.DecodeString(strings.TrimSpace(string(raw)))
if err != nil {
return nil, fmt.Errorf("key file is not valid hex: %w", err)
}
if len(decoded) != ed25519.PrivateKeySize {
return nil, fmt.Errorf("key file has %d bytes, expected %d (ed25519 private key)", len(decoded), ed25519.PrivateKeySize)
}
return ed25519.PrivateKey(decoded), nil
}
// sign encodes payload the same way internal/license.Parse decodes it —
// signature covers the base64 payload TEXT, not the raw JSON bytes, so
// both sides agree on exactly one byte sequence. See that package's Parse
// doc comment for why.
func sign(priv ed25519.PrivateKey, p payload) (string, error) {
payloadJSON, err := json.Marshal(p)
if err != nil {
return "", fmt.Errorf("encoding payload: %w", err)
}
payloadB64 := base64.RawURLEncoding.EncodeToString(payloadJSON)
sig := ed25519.Sign(priv, []byte(payloadB64))
sigB64 := base64.RawURLEncoding.EncodeToString(sig)
return payloadB64 + "." + sigB64, nil
}
@@ -15,6 +15,8 @@ import ShiftPromptModal from '../shifts/ShiftPromptModal'
import NewEntryTypeModal from '../orders/NewEntryTypeModal'
import NewOnsiteBookingModal from '../bookings/NewOnsiteBookingModal'
import MultiIntakeModal from '../orders/MultiIntakeModal'
import { LicenseGate } from '../settings/LicenseGate'
import { TrialBanner } from '../settings/TrialBanner'
const PAGE_TITLES = {
'/kanban': 'Канбан',
@@ -113,6 +115,8 @@ const AdminLayout = () => {
<CartProvider>
<div data-shop-mode={shopMode ? 'true' : undefined} style={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
<ShiftPromptModal />
<LicenseGate />
<TrialBanner />
{isMobile && sidebarOpen && (
<div
onClick={() => setSidebarOpen(false)}
+15
View File
@@ -54,6 +54,14 @@ async function request(baseUrl, path, options = {}) {
const data = isJson ? await res.json().catch(() => null) : null
if (!res.ok) {
// internal/license's middleware 402s every non-exempt request once the
// trial has run out with no valid key — a global event (not a redirect,
// unlike 401 above) lets LicenseGate show a blocking overlay from
// wherever the user happens to be, since /api/settings itself stays
// reachable (exempt) so they can still paste a key in to clear it.
if (res.status === 402 && data?.error === 'trial_expired') {
window.dispatchEvent(new CustomEvent('license:blocked'))
}
throw new ApiError(data?.error || `request failed: ${res.status}`, res.status)
}
return data
@@ -634,6 +642,13 @@ export const settings = {
update: (body) => request(PRODUCTION_URL, '/api/settings', { method: 'PUT', body: JSON.stringify(body) }),
}
// Пробный период / лицензия (internal/license) — отдельный лёгкий эндпоинт
// вместо settings.get(), доступный любому staff-логину (не только owner),
// чтобы баннер триала мог показаться каждому, кто вошёл.
export const license = {
status: () => request(PRODUCTION_URL, '/api/license/status'),
}
// Кастомные поля приёмки — owner добавляет свои вопросы к форме заявки
// (пломба цела? с зарядкой? и т.п.) без правки кода. list() — активные,
// для формы заявки, доступен всем ролям; listAll() — включая архивные,
@@ -0,0 +1,47 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { ShieldX } from 'lucide-react'
// Full-screen blocking overlay — shown the moment any API call 402s with
// trial_expired (see lib/api.js's request(), which dispatches this event).
// Doesn't redirect: /settings/license itself stays reachable (exempt on
// the backend) so a staff member can paste a key in without navigating away
// first, and this overlay just sits on top until they do.
export function LicenseGate() {
const [blocked, setBlocked] = useState(false)
const navigate = useNavigate()
useEffect(() => {
const onBlocked = () => setBlocked(true)
window.addEventListener('license:blocked', onBlocked)
return () => window.removeEventListener('license:blocked', onBlocked)
}, [])
if (!blocked) return null
return (
<div style={{
position: 'fixed', inset: 0, zIndex: 1000,
background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(3px)',
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '20px',
}}>
<div className="glass-panel" style={{ maxWidth: '420px', padding: '28px', textAlign: 'center' }}>
<ShieldX size={32} style={{ color: 'var(--accent-red, #d64545)', marginBottom: '12px' }} />
<h2 style={{ fontFamily: 'var(--font-heading)', fontSize: '1.15rem', marginBottom: '8px' }}>
Пробный период закончился
</h2>
<p style={{ fontSize: '0.88rem', color: 'var(--color-text-muted)', marginBottom: '20px' }}>
Введите лицензионный ключ в Настройках, чтобы продолжить работу.
</p>
<button
type="button"
className="btn-primary"
style={{ padding: '10px 20px', fontSize: '0.88rem' }}
onClick={() => { setBlocked(false); navigate('/settings/license') }}
>
Открыть настройки лицензии
</button>
</div>
</div>
)
}
@@ -0,0 +1,74 @@
import { useEffect, useState } from 'react'
import { ShieldCheck, ShieldAlert, ShieldX, Clock } from 'lucide-react'
import * as api from '../lib/api'
import { Section, Field, SaveFooter } from './SettingsFields'
import { useSettingsForm } from './useSettingsForm'
// Live trial/license state (internal/license) — fetched separately from
// api.settings.get() below because /api/license/status is open to any staff
// role (every login should see the countdown), while /api/settings is
// settingsPerm-gated. Two different audiences, two different endpoints.
function StatusPanel() {
const [status, setStatus] = useState(null)
const [error, setError] = useState('')
useEffect(() => {
let cancelled = false
api.license.status()
.then((data) => { if (!cancelled) setStatus(data) })
.catch((err) => { if (!cancelled) setError(err.message) })
return () => { cancelled = true }
}, [])
if (error) return <p style={{ fontSize: '0.85rem', color: 'var(--accent-red, #d64545)' }}>{error}</p>
if (!status) return <p style={{ fontSize: '0.85rem', color: 'var(--color-text-muted)' }}>Загрузка</p>
const tone = status.licensed
? { Icon: ShieldCheck, color: 'var(--accent-green, #2fae60)', text: 'Лицензия активна' }
: status.trial_active
? { Icon: Clock, color: 'var(--accent-orange, #d98a1f)', text: `Пробный период — осталось ${status.trial_days_left} дн.` }
: { Icon: ShieldX, color: 'var(--accent-red, #d64545)', text: 'Пробный период закончился' }
return (
<div className="glass-panel" style={{ padding: '20px', marginBottom: '18px', display: 'flex', alignItems: 'center', gap: '12px' }}>
<tone.Icon size={22} style={{ color: tone.color, flexShrink: 0 }} />
<div>
<p style={{ fontSize: '0.95rem', fontWeight: 560 }}>{tone.text}</p>
{!status.licensed && !status.trial_active && (
<p style={{ fontSize: '0.8rem', color: 'var(--color-text-muted)' }}>
Введите лицензионный ключ ниже, чтобы продолжить работу без ограничений.
</p>
)}
</div>
</div>
)
}
// Ошибка проверки формата ключа приходит от валидации на сервере
// (license.Parse внутри handler.go) — не отдельная проверка на фронте,
// чтобы не дублировать формат base64(payload).base64(sig) в двух местах.
const LicenseTab = () => {
const { form, loading, error, saving, saved, load, set, save } = useSettingsForm(api)
useEffect(() => { load() }, []) // eslint-disable-line react-hooks/exhaustive-deps
return (
<div>
<StatusPanel />
{loading && <p style={{ color: 'var(--text-muted)' }}>Загрузка...</p>}
{error && !form && <p style={{ color: 'var(--accent-red)' }}>{error}</p>}
{form && (
<>
<Section title="Лицензионный ключ" hint="Ключ выдаётся при покупке лицензии или подписки. Проверяется офлайн — интернет для этого не нужен.">
<Field label="Ключ" field="license_key" value={form.license_key} onChange={set} placeholder="вставьте ключ сюда" />
</Section>
<SaveFooter error={error} saving={saving} saved={saved} form={form} onSave={save} />
</>
)}
</div>
)
}
export default LicenseTab
@@ -0,0 +1,51 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { Clock, X } from 'lucide-react'
import * as api from '../lib/api'
// Proactive countdown, separate from LicenseGate's reactive 402 overlay —
// this warns a few days *before* the trial actually locks anything, so
// nobody's first sign of a problem is a hard block mid-task. Only renders
// in the last 3 days of an unlicensed trial; silent otherwise. Dismissal
// is per-page-load (no persistence) — deliberately, so it comes back next
// login rather than someone dismissing it once on day 5 and never seeing
// it again before expiry.
export function TrialBanner() {
const [status, setStatus] = useState(null)
const [dismissed, setDismissed] = useState(false)
useEffect(() => {
let cancelled = false
api.license.status()
.then((data) => { if (!cancelled) setStatus(data) })
.catch(() => {}) // silent — LicenseGate/inline errors already cover a failed check
return () => { cancelled = true }
}, [])
if (dismissed || !status || status.licensed || !status.trial_active || status.trial_days_left > 3) return null
return (
<div style={{
background: 'color-mix(in srgb, var(--accent-orange, #d98a1f) 15%, var(--color-surface))',
borderBottom: '1px solid var(--accent-orange, #d98a1f)',
padding: '9px 20px', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '10px',
fontSize: '0.85rem', position: 'relative',
}}>
<Clock size={15} style={{ color: 'var(--accent-orange, #d98a1f)', flexShrink: 0 }} />
<span>
Пробный период заканчивается через {status.trial_days_left} {status.trial_days_left === 1 ? 'день' : 'дня'} {' '}
<Link to="/settings/license" style={{ color: 'var(--color-accent-fg)', textDecoration: 'underline' }}>
ввести лицензионный ключ
</Link>
</span>
<button
type="button"
onClick={() => setDismissed(true)}
aria-label="Скрыть"
style={{ position: 'absolute', right: '14px', background: 'none', color: 'var(--color-text-muted)', padding: '2px' }}
>
<X size={15} />
</button>
</div>
)
}
+3 -1
View File
@@ -1,8 +1,9 @@
import { Settings, MonitorSmartphone, Users, KeyRound, ListChecks, Tags, Hammer, FileText, Blocks, ToggleLeft, Columns3, LayoutTemplate, Sparkles, Star, Plug } from 'lucide-react'
import { Settings, MonitorSmartphone, Users, KeyRound, ListChecks, Tags, Hammer, FileText, Blocks, ToggleLeft, Columns3, LayoutTemplate, Sparkles, Star, Plug, ShieldCheck } from 'lucide-react'
import SettingsPage from './SettingsPage'
import IntegrationsPage from './IntegrationsPage'
import InterfaceTab from './InterfaceTab'
import UpdatesTab from './UpdatesTab'
import LicenseTab from './LicenseTab'
import SectionsTab from './SectionsTab'
import OrderStatusesPage from './OrderStatusesPage'
import PcBuildPresetsPage from './PcBuildPresetsPage'
@@ -48,6 +49,7 @@ export const SETTINGS_TABS = [
{ path: 'roles', label: 'Роли', icon: KeyRound, permission: 'staff', group: 'access', Component: RolesPage },
{ path: 'modules', label: 'Модули', icon: Blocks, permission: 'modules', group: 'system', Component: ModulesPage },
{ path: 'site-builder', label: 'Конструктор сайта', icon: LayoutTemplate, permission: 'settings', group: 'system', Component: SiteBuilderPage },
{ path: 'license', label: 'Лицензия', icon: ShieldCheck, permission: 'settings', group: 'system', Component: LicenseTab },
]
// Order here is display order — groups render top to bottom, tabs within a