Files
sencho/frontend/src/index.css
T
Anso 32a7d53b2b feat: RBAC, atomic deployments, fleet backups, and licensing (Pro) (#185)
* feat: add RBAC viewer accounts, atomic deployments, and fleet-wide backups (Pro)

Introduces three Pro-tier features:

- RBAC: Multi-user system with admin/viewer roles, user management UI,
  automatic migration from single-admin credentials, viewer restrictions
  across the entire UI (read-only editor, hidden action buttons)

- Atomic Deployments: Pre-deploy file backup to .sencho-backup/, automatic
  rollback on health probe failure, manual rollback button, health probes
  added to stack updates, webhook-triggered deploys use atomic rollback

- Fleet-Wide Backups: Point-in-time snapshots of compose files across all
  nodes (local + remote), stored centrally in SQLite, per-stack restore
  with optional redeploy, graceful handling of offline nodes

* fix(settings): use correct ProGate prop name in UsersSection

* fix(settings): remove unused isPro prop from UsersSection

* fix(auth): fetch user info after login and setup so isAdmin is set correctly

* feat(pricing): revise pricing strategy and enforce variant-based seat limits

Raise Personal Pro from $49/yr to $69/yr with 3 viewer seats (up from 1).
Add $15/mo billing option for Team Pro. Mark lifetime pricing as a
90-day early-adopter offer. Store Lemon Squeezy variant_name on
activation/validation and enforce seat limits server-side per variant.

* feat(licensing): add Lemon Squeezy checkout, webhook, and billing portal integration

Server-side checkout URL generation (POST /api/checkout) with admin email
pre-fill and instance_id custom data. HMAC-SHA256 verified webhook endpoint
(POST /api/webhooks/lemonsqueezy) handling order, subscription, and payment
lifecycle events for automatic license activation. Customer billing portal
link stored from webhook events and exposed via GET /api/billing/portal.
In-app checkout buttons in Settings with manual license key fallback.

* fix(licensing): exempt Lemon Squeezy webhook from auth middleware

The catch-all auth middleware on /api/* was blocking the public webhook
endpoint. Added /webhooks/lemonsqueezy to the exemption list alongside
/auth/* and /webhooks/:id/trigger.

* feat(pricing): update pricing to final live rates

Personal Pro: $7.99/month, $69.99/year, $249 lifetime.
Team Pro: $49.99/month, $499.99/year, $1,499 lifetime.
Added personal_monthly checkout variant across backend, frontend, and website.

* refactor(licensing): remove server-side checkout/webhook for self-hosted model

Sencho is self-hosted — each user runs their own instance, so there is
no central server to receive webhooks or hold the store API key. Replaced
in-app checkout buttons with a "View Pricing" redirect to sencho.io and
kept manual license key activation as the primary flow.

- Delete LemonSqueezyService (checkout, webhook, HMAC verification)
- Remove POST /api/checkout, GET /api/billing/portal, POST /api/webhooks/lemonsqueezy
- Remove raw body parser and auth exemption for webhook route
- Remove all LEMONSQUEEZY_* env vars from .env.example
- Replace checkout buttons in SettingsModal with single "View Pricing" button
- Simplify LicenseContext checkout to open sencho.io pricing page
- Update licensing docs to reflect website-based purchase flow

* chore: normalize em-dashes to hyphens across codebase (linter)

* chore: remove accidentally tracked directories from index
2026-03-26 21:58:24 -04:00

352 lines
13 KiB
CSS

@import "tailwindcss";
@plugin 'tailwindcss-animate';
@custom-variant dark (&:is(.dark *));
/* ─────────────────────────────────────────────────────────────
LIGHT THEME
───────────────────────────────────────────────────────────── */
:root {
--background: oklch(1 0 0);
--foreground: oklch(0 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0 0 0);
--popover: oklch(0.9900 0 0);
--popover-foreground: oklch(0 0 0);
--primary: oklch(0 0 0);
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.9400 0 0);
--secondary-foreground: oklch(0 0 0);
--muted: oklch(0.9700 0 0);
--muted-foreground: oklch(0.4400 0 0);
--accent: oklch(0.9400 0 0);
--accent-foreground: oklch(0 0 0);
--destructive: oklch(0.6300 0.1900 23.0300);
--destructive-foreground: oklch(1 0 0);
--border: oklch(0.9200 0 0);
--input: oklch(0.9400 0 0);
--ring: oklch(0.50 0.14 200);
/* Brand - technical cyan, the signature accent */
--brand: oklch(0.50 0.14 200);
--brand-foreground: oklch(1 0 0);
--brand-muted: oklch(0.50 0.14 200 / 0.12);
/* Charts */
--chart-1: oklch(0.8100 0.1700 75.3500);
--chart-2: oklch(0.5500 0.2200 264.5300);
--chart-3: oklch(0.7200 0 0);
--chart-4: oklch(0.9200 0 0);
--chart-5: oklch(0.5600 0 0);
/* Sidebar */
--sidebar: oklch(0.9900 0 0);
--sidebar-foreground: oklch(0 0 0);
--sidebar-primary: oklch(0 0 0);
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(0.9400 0 0);
--sidebar-accent-foreground: oklch(0 0 0);
--sidebar-border: oklch(0.9400 0 0);
--sidebar-ring: oklch(0.50 0.14 200);
/* Typography */
--font-sans: 'Geist', sans-serif;
--font-serif: Georgia, serif;
--font-mono: 'Geist Mono', monospace;
/* Shape */
--radius: 0.5rem;
/* Shadows */
--shadow-x: 0px;
--shadow-y: 1px;
--shadow-blur: 2px;
--shadow-spread: 0px;
--shadow-opacity: 0.18;
--shadow-color: hsl(0 0% 0%);
--shadow-2xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09);
--shadow-xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09);
--shadow-sm: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18);
--shadow: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18);
--shadow-md: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 2px 4px -1px hsl(0 0% 0% / 0.18);
--shadow-lg: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 4px 6px -1px hsl(0 0% 0% / 0.18);
--shadow-xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 8px 10px -1px hsl(0 0% 0% / 0.18);
--shadow-2xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.45);
/* Motion timing */
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
--duration-fast: 150ms;
--duration-base: 220ms;
--duration-slow: 350ms;
--tracking-normal: 0em;
--spacing: 0.25rem;
}
/* ─────────────────────────────────────────────────────────────
DARK THEME
───────────────────────────────────────────────────────────── */
.dark {
--background: oklch(0.1400 0 0);
--foreground: oklch(1 0 0);
--card: oklch(0.1600 0 0);
--card-foreground: oklch(1 0 0);
--popover: oklch(0.1800 0 0);
--popover-foreground: oklch(1 0 0);
--primary: oklch(1 0 0);
--primary-foreground: oklch(0 0 0);
--secondary: oklch(0.2500 0 0);
--secondary-foreground: oklch(1 0 0);
--muted: oklch(0.2300 0 0);
--muted-foreground: oklch(0.7200 0 0);
--accent: oklch(0.3200 0 0);
--accent-foreground: oklch(1 0 0);
--destructive: oklch(0.6900 0.2000 23.9100);
--destructive-foreground: oklch(0 0 0);
--border: oklch(0.2600 0 0);
--input: oklch(0.3200 0 0);
--ring: oklch(0.72 0.14 200);
/* Brand - bright technical cyan on dark backgrounds */
--brand: oklch(0.72 0.14 200);
--brand-foreground: oklch(0.05 0 0);
--brand-muted: oklch(0.72 0.14 200 / 0.12);
/* Charts */
--chart-1: oklch(0.8100 0.1700 75.3500);
--chart-2: oklch(0.5800 0.2100 260.8400);
--chart-3: oklch(0.5600 0 0);
--chart-4: oklch(0.4400 0 0);
--chart-5: oklch(0.9200 0 0);
/* Sidebar */
--sidebar: oklch(0.1800 0 0);
--sidebar-foreground: oklch(1 0 0);
--sidebar-primary: oklch(1 0 0);
--sidebar-primary-foreground: oklch(0 0 0);
--sidebar-accent: oklch(0.3200 0 0);
--sidebar-accent-foreground: oklch(1 0 0);
--sidebar-border: oklch(0.3200 0 0);
--sidebar-ring: oklch(0.72 0.14 200);
/* Typography */
--font-sans: 'Geist', sans-serif;
--font-serif: Georgia, serif;
--font-mono: 'Geist Mono', monospace;
/* Shape */
--radius: 0.5rem;
/* Shadows - deeper on dark backgrounds */
--shadow-x: 0px;
--shadow-y: 1px;
--shadow-blur: 2px;
--shadow-spread: 0px;
--shadow-opacity: 0.35;
--shadow-color: hsl(0 0% 0%);
--shadow-2xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.18);
--shadow-xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.18);
--shadow-sm: 0px 1px 2px 0px hsl(0 0% 0% / 0.35), 0px 1px 2px -1px hsl(0 0% 0% / 0.35);
--shadow: 0px 1px 2px 0px hsl(0 0% 0% / 0.35), 0px 1px 2px -1px hsl(0 0% 0% / 0.35);
--shadow-md: 0px 1px 2px 0px hsl(0 0% 0% / 0.35), 0px 2px 4px -1px hsl(0 0% 0% / 0.35);
--shadow-lg: 0px 1px 2px 0px hsl(0 0% 0% / 0.35), 0px 4px 6px -1px hsl(0 0% 0% / 0.35);
--shadow-xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.35), 0px 8px 10px -1px hsl(0 0% 0% / 0.35);
--shadow-2xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.65);
}
/* ─────────────────────────────────────────────────────────────
TAILWIND THEME MAPPING
───────────────────────────────────────────────────────────── */
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-brand: var(--brand);
--color-brand-foreground: var(--brand-foreground);
--color-brand-muted: var(--brand-muted);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--font-serif: var(--font-serif);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--shadow-2xs: var(--shadow-2xs);
--shadow-xs: var(--shadow-xs);
--shadow-sm: var(--shadow-sm);
--shadow: var(--shadow);
--shadow-md: var(--shadow-md);
--shadow-lg: var(--shadow-lg);
--shadow-xl: var(--shadow-xl);
--shadow-2xl: var(--shadow-2xl);
}
/* ─────────────────────────────────────────────────────────────
BASE STYLES
───────────────────────────────────────────────────────────── */
@layer base {
:root {
color-scheme: dark;
}
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
/* ─────────────────────────────────────────────────────────────
TYPOGRAPHY & RENDERING
───────────────────────────────────────────────────────────── */
html,
body,
#root {
height: 100%;
width: 100%;
overflow: hidden;
}
body {
font-family: var(--font-sans), system-ui, -apple-system, BlinkMacSystemFont,
'Segoe UI', sans-serif;
line-height: 1.5;
font-weight: 400;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* ─────────────────────────────────────────────────────────────
SCROLLBAR
───────────────────────────────────────────────────────────── */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: oklch(0.40 0 0 / 0.5);
border-radius: 4px;
transition: background var(--duration-fast) ease;
}
::-webkit-scrollbar-thumb:hover {
background: oklch(0.55 0 0 / 0.7);
}
.dark ::-webkit-scrollbar-thumb {
background: oklch(0.35 0 0 / 0.6);
}
.dark ::-webkit-scrollbar-thumb:hover {
background: oklch(0.50 0 0 / 0.8);
}
/* ─────────────────────────────────────────────────────────────
ANIMATION KEYFRAMES
───────────────────────────────────────────────────────────── */
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes fade-up {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fade-down {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slide-in-left {
from { opacity: 0; transform: translateX(-12px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes scale-in {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
/* ─────────────────────────────────────────────────────────────
ANIMATION UTILITIES
───────────────────────────────────────────────────────────── */
.animate-fade-in {
animation: fade-in var(--duration-base) var(--ease-out-expo) both;
}
.animate-fade-up {
animation: fade-up var(--duration-base) var(--ease-out-expo) both;
}
.animate-fade-down {
animation: fade-down var(--duration-base) var(--ease-out-expo) both;
}
.animate-slide-in-left {
animation: slide-in-left var(--duration-base) var(--ease-out-expo) both;
}
.animate-scale-in {
animation: scale-in var(--duration-fast) var(--ease-spring) both;
}
/* Stagger delay helpers */
.animate-delay-50 { animation-delay: 50ms; }
.animate-delay-100 { animation-delay: 100ms; }
.animate-delay-150 { animation-delay: 150ms; }
.animate-delay-200 { animation-delay: 200ms; }
.animate-delay-300 { animation-delay: 300ms; }
/* ─────────────────────────────────────────────────────────────
PREFERS REDUCED MOTION - zero-cost accessibility
───────────────────────────────────────────────────────────── */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* ─────────────────────────────────────────────────────────────
XTERM.JS
───────────────────────────────────────────────────────────── */
.xterm {
padding: 0;
}