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:
@@ -31,6 +31,7 @@ import (
|
|||||||
"production/internal/imei"
|
"production/internal/imei"
|
||||||
"production/internal/inventory"
|
"production/internal/inventory"
|
||||||
"production/internal/kkm/kkmserver"
|
"production/internal/kkm/kkmserver"
|
||||||
|
"production/internal/license"
|
||||||
"production/internal/loyalty"
|
"production/internal/loyalty"
|
||||||
"production/internal/manufacture"
|
"production/internal/manufacture"
|
||||||
"production/internal/maxbot"
|
"production/internal/maxbot"
|
||||||
@@ -237,6 +238,11 @@ func main() {
|
|||||||
// /api/module-control/* routes (see internal/modulecontrol's doc
|
// /api/module-control/* routes (see internal/modulecontrol's doc
|
||||||
// comment on why a maintenance flag must never block its own toggle).
|
// comment on why a maintenance flag must never block its own toggle).
|
||||||
app.Use(moduleControlHandler.Middleware())
|
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 {
|
app.Get("/health", func(c *fiber.Ctx) error {
|
||||||
return c.JSON(fiber.Map{"ok": true})
|
return c.JSON(fiber.Map{"ok": true})
|
||||||
@@ -645,6 +651,9 @@ func main() {
|
|||||||
|
|
||||||
// settingsPerm — see the comment on its declaration above; still the
|
// settingsPerm — see the comment on its declaration above; still the
|
||||||
// most sensitive gate, holds live API keys/bot tokens.
|
// 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.Get("/settings", staffAuth, staffLimit, settingsPerm, settingsHandler.Get)
|
||||||
api.Put("/settings", staffAuth, staffLimit, settingsPerm, settingsHandler.Update)
|
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"
|
"strconv"
|
||||||
|
|
||||||
"production/internal/auth"
|
"production/internal/auth"
|
||||||
|
"production/internal/license"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
@@ -183,6 +184,7 @@ type updateInput struct {
|
|||||||
ClientVkCommunityID *string `json:"client_vk_community_id"`
|
ClientVkCommunityID *string `json:"client_vk_community_id"`
|
||||||
DiaxProEmail *string `json:"diaxpro_email"`
|
DiaxProEmail *string `json:"diaxpro_email"`
|
||||||
DiaxProPassword *string `json:"diaxpro_password"`
|
DiaxProPassword *string `json:"diaxpro_password"`
|
||||||
|
LicenseKey *string `json:"license_key"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update is a partial update — every field is optional and COALESCEd
|
// 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()})
|
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(), `
|
_, err := h.db.Exec(context.Background(), `
|
||||||
UPDATE settings SET
|
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),
|
client_vk_community_id = COALESCE($42, client_vk_community_id),
|
||||||
diaxpro_email = COALESCE($43, diaxpro_email),
|
diaxpro_email = COALESCE($43, diaxpro_email),
|
||||||
diaxpro_password = COALESCE($44, diaxpro_password),
|
diaxpro_password = COALESCE($44, diaxpro_password),
|
||||||
|
license_key = COALESCE($45, license_key),
|
||||||
updated_at = NOW(),
|
updated_at = NOW(),
|
||||||
updated_by_staff_name = $45
|
updated_by_staff_name = $46
|
||||||
WHERE id = 1`,
|
WHERE id = 1`,
|
||||||
body.BusinessName, body.BusinessINN, body.BusinessKPP, body.BusinessAddress, body.BusinessPhone,
|
body.BusinessName, body.BusinessINN, body.BusinessKPP, body.BusinessAddress, body.BusinessPhone,
|
||||||
body.BusinessBankName, body.BusinessBankAccount, body.BusinessBankBIK, body.BusinessBankCorrAccount,
|
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.MaxBotToken, body.MaxWebhookSecret, body.ClientMaxBotUsername,
|
||||||
body.VkGroupToken, body.VkGroupID, body.VkSecretKey, body.VkConfirmationCode, body.ClientVkCommunityID,
|
body.VkGroupToken, body.VkGroupID, body.VkSecretKey, body.VkConfirmationCode, body.ClientVkCommunityID,
|
||||||
body.DiaxProEmail, body.DiaxProPassword,
|
body.DiaxProEmail, body.DiaxProPassword,
|
||||||
|
body.LicenseKey,
|
||||||
auth.StaffName(c),
|
auth.StaffName(c),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -32,52 +32,59 @@ const defaultGeminiModel = "gemini-2.0-flash"
|
|||||||
const defaultTGAPIBaseURL = "http://telegram-bot-api:8081"
|
const defaultTGAPIBaseURL = "http://telegram-bot-api:8081"
|
||||||
|
|
||||||
type Settings struct {
|
type Settings struct {
|
||||||
BusinessName string `json:"business_name"`
|
BusinessName string `json:"business_name"`
|
||||||
BusinessINN string `json:"business_inn"`
|
BusinessINN string `json:"business_inn"`
|
||||||
BusinessKPP string `json:"business_kpp"`
|
BusinessKPP string `json:"business_kpp"`
|
||||||
BusinessAddress string `json:"business_address"`
|
BusinessAddress string `json:"business_address"`
|
||||||
BusinessPhone string `json:"business_phone"`
|
BusinessPhone string `json:"business_phone"`
|
||||||
BusinessBankName string `json:"business_bank_name"`
|
BusinessBankName string `json:"business_bank_name"`
|
||||||
BusinessBankAccount string `json:"business_bank_account"`
|
BusinessBankAccount string `json:"business_bank_account"`
|
||||||
BusinessBankBIK string `json:"business_bank_bik"`
|
BusinessBankBIK string `json:"business_bank_bik"`
|
||||||
BusinessBankCorrAccount string `json:"business_bank_corr_account"`
|
BusinessBankCorrAccount string `json:"business_bank_corr_account"`
|
||||||
GeminiAPIKey string `json:"gemini_api_key"`
|
GeminiAPIKey string `json:"gemini_api_key"`
|
||||||
GeminiModel string `json:"gemini_model"`
|
GeminiModel string `json:"gemini_model"`
|
||||||
TGBotToken string `json:"tg_bot_token"`
|
TGBotToken string `json:"tg_bot_token"`
|
||||||
TGChatID string `json:"tg_chat_id"`
|
TGChatID string `json:"tg_chat_id"`
|
||||||
TGAPIBaseURL string `json:"tg_api_base_url"`
|
TGAPIBaseURL string `json:"tg_api_base_url"`
|
||||||
IMEIAPIKey string `json:"imei_api_key"`
|
IMEIAPIKey string `json:"imei_api_key"`
|
||||||
SMSProvider string `json:"sms_provider"`
|
SMSProvider string `json:"sms_provider"`
|
||||||
SMSAPIID string `json:"sms_api_id"`
|
SMSAPIID string `json:"sms_api_id"`
|
||||||
SMSFrom string `json:"sms_from"`
|
SMSFrom string `json:"sms_from"`
|
||||||
SMSEnabled bool `json:"sms_enabled"`
|
SMSEnabled bool `json:"sms_enabled"`
|
||||||
ClientTGBotUsername string `json:"client_tg_bot_username"`
|
ClientTGBotUsername string `json:"client_tg_bot_username"`
|
||||||
TGWebhookSecret string `json:"tg_webhook_secret"`
|
TGWebhookSecret string `json:"tg_webhook_secret"`
|
||||||
ClientNotifyEnabled bool `json:"client_notify_enabled"`
|
ClientNotifyEnabled bool `json:"client_notify_enabled"`
|
||||||
PublicTrackingURL string `json:"public_tracking_url"`
|
PublicTrackingURL string `json:"public_tracking_url"`
|
||||||
LoyaltyEnabled bool `json:"loyalty_enabled"`
|
LoyaltyEnabled bool `json:"loyalty_enabled"`
|
||||||
LoyaltyAccrualPercent string `json:"loyalty_accrual_percent"`
|
LoyaltyAccrualPercent string `json:"loyalty_accrual_percent"`
|
||||||
DefaultWarrantyDays int `json:"default_warranty_days"`
|
DefaultWarrantyDays int `json:"default_warranty_days"`
|
||||||
KkmEnabled bool `json:"kkm_enabled"`
|
KkmEnabled bool `json:"kkm_enabled"`
|
||||||
KkmServerURL string `json:"kkm_server_url"`
|
KkmServerURL string `json:"kkm_server_url"`
|
||||||
KkmLogin string `json:"kkm_login"`
|
KkmLogin string `json:"kkm_login"`
|
||||||
KkmPassword string `json:"kkm_password"`
|
KkmPassword string `json:"kkm_password"`
|
||||||
KkmNumDevice string `json:"kkm_num_device"`
|
KkmNumDevice string `json:"kkm_num_device"`
|
||||||
KkmTax string `json:"kkm_tax"`
|
KkmTax string `json:"kkm_tax"`
|
||||||
DiscountApprovalEnabled bool `json:"discount_approval_enabled"`
|
DiscountApprovalEnabled bool `json:"discount_approval_enabled"`
|
||||||
DiscountApprovalThresholdPercent string `json:"discount_approval_threshold_percent"`
|
DiscountApprovalThresholdPercent string `json:"discount_approval_threshold_percent"`
|
||||||
MaxBotToken string `json:"max_bot_token"`
|
MaxBotToken string `json:"max_bot_token"`
|
||||||
MaxWebhookSecret string `json:"max_webhook_secret"`
|
MaxWebhookSecret string `json:"max_webhook_secret"`
|
||||||
ClientMaxBotUsername string `json:"client_max_bot_username"`
|
ClientMaxBotUsername string `json:"client_max_bot_username"`
|
||||||
VkGroupToken string `json:"vk_group_token"`
|
VkGroupToken string `json:"vk_group_token"`
|
||||||
VkGroupID string `json:"vk_group_id"`
|
VkGroupID string `json:"vk_group_id"`
|
||||||
VkSecretKey string `json:"vk_secret_key"`
|
VkSecretKey string `json:"vk_secret_key"`
|
||||||
VkConfirmationCode string `json:"vk_confirmation_code"`
|
VkConfirmationCode string `json:"vk_confirmation_code"`
|
||||||
ClientVkCommunityID string `json:"client_vk_community_id"`
|
ClientVkCommunityID string `json:"client_vk_community_id"`
|
||||||
DiaxProEmail string `json:"diaxpro_email"`
|
DiaxProEmail string `json:"diaxpro_email"`
|
||||||
DiaxProPassword string `json:"diaxpro_password"`
|
DiaxProPassword string `json:"diaxpro_password"`
|
||||||
UpdatedAt *time.Time `json:"updated_at"`
|
// TrialStartedAt is stamped once by migration 067 the moment this
|
||||||
UpdatedByStaffName string `json:"updated_by_staff_name"`
|
// 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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch reads the singleton settings row, one indexed SELECT on a 1-row
|
// Fetch reads the singleton settings row, one indexed SELECT on a 1-row
|
||||||
@@ -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_group_token, ''), COALESCE(vk_group_id, ''), COALESCE(vk_secret_key, ''),
|
||||||
COALESCE(vk_confirmation_code, ''), COALESCE(client_vk_community_id, ''),
|
COALESCE(vk_confirmation_code, ''), COALESCE(client_vk_community_id, ''),
|
||||||
COALESCE(diaxpro_email, ''), COALESCE(diaxpro_password, ''),
|
COALESCE(diaxpro_email, ''), COALESCE(diaxpro_password, ''),
|
||||||
|
trial_started_at, COALESCE(license_key, ''),
|
||||||
updated_at, COALESCE(updated_by_staff_name, '')
|
updated_at, COALESCE(updated_by_staff_name, '')
|
||||||
FROM settings WHERE id = 1`,
|
FROM settings WHERE id = 1`,
|
||||||
).Scan(
|
).Scan(
|
||||||
@@ -124,6 +132,7 @@ func Fetch(ctx context.Context, db *pgxpool.Pool) (Settings, error) {
|
|||||||
&s.MaxBotToken, &s.MaxWebhookSecret, &s.ClientMaxBotUsername,
|
&s.MaxBotToken, &s.MaxWebhookSecret, &s.ClientMaxBotUsername,
|
||||||
&s.VkGroupToken, &s.VkGroupID, &s.VkSecretKey, &s.VkConfirmationCode, &s.ClientVkCommunityID,
|
&s.VkGroupToken, &s.VkGroupID, &s.VkSecretKey, &s.VkConfirmationCode, &s.ClientVkCommunityID,
|
||||||
&s.DiaxProEmail, &s.DiaxProPassword,
|
&s.DiaxProEmail, &s.DiaxProPassword,
|
||||||
|
&s.TrialStartedAt, &s.LicenseKey,
|
||||||
&s.UpdatedAt, &s.UpdatedByStaffName,
|
&s.UpdatedAt, &s.UpdatedByStaffName,
|
||||||
)
|
)
|
||||||
if err != nil {
|
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 NewEntryTypeModal from '../orders/NewEntryTypeModal'
|
||||||
import NewOnsiteBookingModal from '../bookings/NewOnsiteBookingModal'
|
import NewOnsiteBookingModal from '../bookings/NewOnsiteBookingModal'
|
||||||
import MultiIntakeModal from '../orders/MultiIntakeModal'
|
import MultiIntakeModal from '../orders/MultiIntakeModal'
|
||||||
|
import { LicenseGate } from '../settings/LicenseGate'
|
||||||
|
import { TrialBanner } from '../settings/TrialBanner'
|
||||||
|
|
||||||
const PAGE_TITLES = {
|
const PAGE_TITLES = {
|
||||||
'/kanban': 'Канбан',
|
'/kanban': 'Канбан',
|
||||||
@@ -113,6 +115,8 @@ const AdminLayout = () => {
|
|||||||
<CartProvider>
|
<CartProvider>
|
||||||
<div data-shop-mode={shopMode ? 'true' : undefined} style={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
|
<div data-shop-mode={shopMode ? 'true' : undefined} style={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
|
||||||
<ShiftPromptModal />
|
<ShiftPromptModal />
|
||||||
|
<LicenseGate />
|
||||||
|
<TrialBanner />
|
||||||
{isMobile && sidebarOpen && (
|
{isMobile && sidebarOpen && (
|
||||||
<div
|
<div
|
||||||
onClick={() => setSidebarOpen(false)}
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
|||||||
@@ -54,6 +54,14 @@ async function request(baseUrl, path, options = {}) {
|
|||||||
const data = isJson ? await res.json().catch(() => null) : null
|
const data = isJson ? await res.json().catch(() => null) : null
|
||||||
|
|
||||||
if (!res.ok) {
|
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)
|
throw new ApiError(data?.error || `request failed: ${res.status}`, res.status)
|
||||||
}
|
}
|
||||||
return data
|
return data
|
||||||
@@ -634,6 +642,13 @@ export const settings = {
|
|||||||
update: (body) => request(PRODUCTION_URL, '/api/settings', { method: 'PUT', body: JSON.stringify(body) }),
|
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 добавляет свои вопросы к форме заявки
|
// Кастомные поля приёмки — owner добавляет свои вопросы к форме заявки
|
||||||
// (пломба цела? с зарядкой? и т.п.) без правки кода. list() — активные,
|
// (пломба цела? с зарядкой? и т.п.) без правки кода. list() — активные,
|
||||||
// для формы заявки, доступен всем ролям; listAll() — включая архивные,
|
// для формы заявки, доступен всем ролям; 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 SettingsPage from './SettingsPage'
|
||||||
import IntegrationsPage from './IntegrationsPage'
|
import IntegrationsPage from './IntegrationsPage'
|
||||||
import InterfaceTab from './InterfaceTab'
|
import InterfaceTab from './InterfaceTab'
|
||||||
import UpdatesTab from './UpdatesTab'
|
import UpdatesTab from './UpdatesTab'
|
||||||
|
import LicenseTab from './LicenseTab'
|
||||||
import SectionsTab from './SectionsTab'
|
import SectionsTab from './SectionsTab'
|
||||||
import OrderStatusesPage from './OrderStatusesPage'
|
import OrderStatusesPage from './OrderStatusesPage'
|
||||||
import PcBuildPresetsPage from './PcBuildPresetsPage'
|
import PcBuildPresetsPage from './PcBuildPresetsPage'
|
||||||
@@ -48,6 +49,7 @@ export const SETTINGS_TABS = [
|
|||||||
{ path: 'roles', label: 'Роли', icon: KeyRound, permission: 'staff', group: 'access', Component: RolesPage },
|
{ path: 'roles', label: 'Роли', icon: KeyRound, permission: 'staff', group: 'access', Component: RolesPage },
|
||||||
{ path: 'modules', label: 'Модули', icon: Blocks, permission: 'modules', group: 'system', Component: ModulesPage },
|
{ path: 'modules', label: 'Модули', icon: Blocks, permission: 'modules', group: 'system', Component: ModulesPage },
|
||||||
{ path: 'site-builder', label: 'Конструктор сайта', icon: LayoutTemplate, permission: 'settings', group: 'system', Component: SiteBuilderPage },
|
{ 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
|
// Order here is display order — groups render top to bottom, tabs within a
|
||||||
|
|||||||
Reference in New Issue
Block a user