Files
sencho/frontend/src/components/settings/CloudBackupSection.tsx
T
Anso 865d792874 feat(pricing): collapse to two tiers (#1309)
* feat(pricing): collapse to two tiers (Community + Admiral)

Collapse Sencho's pricing from three tiers (Community / Skipper / Admiral)
to two: a generous free Community tier and a single paid Admiral tier. The
Skipper tier is removed.

Now free in Community: auto-heal, auto-update, scheduled operations,
webhooks, notification routing, Fleet Actions and bulk operations, SSO
preset providers (Google / GitHub / Okta), unlimited users with admin and
viewer roles, and deploy safety (atomic deploys, auto-rollback, and
one-click rollback).

Admiral (paid) is focused on running and governing a fleet: blueprints,
Fleet Secrets, deploy enforcement, vulnerability report export, audit log,
host console, private registries, mesh networking, node cordon, managed
cloud backup, LDAP / Active Directory SSO, and the advanced RBAC roles
(deployer, node-admin, auditor) with per-resource scoped assignments.

Internally the license variant distinction is removed so tier is binary
(community / paid). License validation still verifies the Lemon Squeezy
store and product before granting paid status.

Docs and the contributor guide are updated to the two-tier model.

* docs(pricing): correct licensing page to two-tier pricing and tidy stale tier wording

The licensing docs page kept the old Admiral pricing plus a Founder
Lifetime column and an Enterprise paragraph after the two-tier collapse.
Update it to $12/month or $99/year, drop the lifetime and Enterprise
content, and link to the pricing page for current pricing.

Also fix stale "Skipper" wording in CLA.md, SUPPORT.md, one test title,
and three test comments. Historical CHANGELOG entries and the
retired-Skipper license-guard test are intentionally left as-is.

* docs: align licensing and SSO pages with the two-tier model

Correct the SSO overview so the Google, GitHub, and Okta presets read as
available on every tier, matching the provider table; only LDAP and Active
Directory require Sencho Admiral. Remove the lifetime-plan references from the
licensing, settings, and troubleshooting pages so they reflect subscription-only
Admiral pricing.

* fix(rbac): omit scoped permissions from /me on the Community tier

Scoped role assignments only take effect on the paid tier, but GET /api/permissions/me returned them unconditionally, so a downgraded instance with leftover assignments rendered per-resource affordances the API then rejected with 403. The endpoint now mirrors the permission middleware and includes scoped permissions only on the paid tier. Adds a regression test covering the downgrade case.

* docs: use custom-pricing wording on the contact page

The two-tier model has no Enterprise tier; reword the contact page's enterprise pricing/deals to custom pricing/deals so it does not imply a tier that no longer exists.
2026-06-04 17:45:53 -04:00

558 lines
26 KiB
TypeScript

import { useEffect, useState, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Skeleton } from '@/components/ui/skeleton';
import { Combobox } from '@/components/ui/combobox';
import { TogglePill } from '@/components/ui/toggle-pill';
import { ConfirmModal } from '@/components/ui/modal';
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import { formatBytes } from '@/lib/utils';
import { useLicense } from '@/context/LicenseContext';
import { Cloud, CloudOff, RefreshCw, CheckCircle2, Loader2, Trash2, Download, ChevronLeft, ChevronRight } from 'lucide-react';
import { SettingsPrimaryButton } from './SettingsActions';
import { useMastheadStats } from './MastheadStatsContext';
type Provider = 'disabled' | 'sencho' | 'custom';
interface CustomConfig {
endpoint: string;
region: string;
bucket: string;
access_key: string;
secret_key: string;
path_prefix: string;
auto_upload: boolean;
}
interface ConfigResponse {
provider: Provider;
sencho_provisioned: boolean;
sencho_provisioned_at: string | null;
custom: CustomConfig;
}
interface UsageResponse {
used_bytes: number;
quota_bytes: number;
object_count: number;
}
interface CloudSnapshotEntry {
objectKey: string;
sizeBytes: number;
lastModified: string | null;
snapshotId: number | null;
}
const EMPTY_CUSTOM: CustomConfig = {
endpoint: '',
region: '',
bucket: '',
access_key: '',
secret_key: '',
path_prefix: 'sencho/',
auto_upload: false,
};
const BASE_PROVIDER_OPTIONS = [
{ value: 'disabled', label: 'Disabled' },
{ value: 'custom', label: 'Custom S3 (BYOB)' },
];
const SENCHO_PROVIDER_OPTION = { value: 'sencho', label: 'Sencho Cloud Backup (included)' };
const PANEL_CLASS = 'rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel p-4 space-y-3';
const PAGE_SIZE = 10;
export function CloudBackupSection() {
const { isPaid } = useLicense();
const providerOptions = isPaid
? [BASE_PROVIDER_OPTIONS[0], SENCHO_PROVIDER_OPTION, BASE_PROVIDER_OPTIONS[1]]
: BASE_PROVIDER_OPTIONS;
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [provider, setProvider] = useState<Provider>('disabled');
const [senchoProvisioned, setSenchoProvisioned] = useState(false);
const [custom, setCustom] = useState<CustomConfig>(EMPTY_CUSTOM);
const [originalSecretSaved, setOriginalSecretSaved] = useState(false);
const [usage, setUsage] = useState<UsageResponse | null>(null);
const [snapshots, setSnapshots] = useState<CloudSnapshotEntry[]>([]);
const [testing, setTesting] = useState(false);
const [provisioning, setProvisioning] = useState(false);
const [deleteKey, setDeleteKey] = useState<string | null>(null);
const [page, setPage] = useState(0);
const totalPages = Math.max(1, Math.ceil(snapshots.length / PAGE_SIZE));
const safePage = Math.min(page, totalPages - 1);
const pagedSnapshots = snapshots.slice(safePage * PAGE_SIZE, (safePage + 1) * PAGE_SIZE);
const needsPagination = snapshots.length > PAGE_SIZE;
const loadConfig = useCallback(async () => {
try {
const res = await apiFetch('/cloud-backup/config');
if (!res.ok) throw new Error(`Failed to load config (${res.status})`);
const data: ConfigResponse = await res.json();
setProvider(data.provider);
setSenchoProvisioned(data.sencho_provisioned);
setCustom({ ...data.custom, secret_key: '' });
setOriginalSecretSaved(!!data.custom.secret_key);
} catch (err) {
toast.error((err as Error)?.message || 'Failed to load cloud backup config.');
} finally {
setLoading(false);
}
}, []);
const loadUsage = useCallback(async () => {
try {
const res = await apiFetch('/cloud-backup/usage');
if (res.ok) setUsage(await res.json());
} catch {
// Usage is informational; failures shouldn't surface as toasts.
}
}, []);
const loadSnapshots = useCallback(async () => {
try {
const res = await apiFetch('/cloud-backup/snapshots');
if (res.ok) setSnapshots(await res.json());
} catch {
// Best-effort; the panel renders empty when listing fails.
}
}, []);
useEffect(() => {
loadConfig();
}, [loadConfig]);
useEffect(() => {
if (provider === 'sencho' && senchoProvisioned) loadUsage();
}, [provider, senchoProvisioned, loadUsage]);
useEffect(() => {
if (provider !== 'disabled') loadSnapshots();
else setSnapshots([]);
}, [provider, loadSnapshots]);
const handleProviderChange = async (next: string) => {
const nextProvider = next as Provider;
setProvider(nextProvider);
if (nextProvider === 'sencho' && !senchoProvisioned) return;
setSaving(true);
try {
const body = nextProvider === 'custom' ? { provider: nextProvider, custom: { ...custom, secret_key: '' } } : { provider: nextProvider };
const res = await apiFetch('/cloud-backup/config', {
method: 'PUT',
body: JSON.stringify(body),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err?.error || 'Failed to save provider');
}
toast.success('Cloud backup provider updated.');
} catch (err) {
toast.error((err as Error)?.message || 'Failed to update provider.');
} finally {
setSaving(false);
}
};
const handleSaveCustom = async () => {
setSaving(true);
try {
const payload = {
provider: 'custom',
custom: {
endpoint: custom.endpoint,
region: custom.region,
bucket: custom.bucket,
access_key: custom.access_key,
secret_key: custom.secret_key || (originalSecretSaved ? '***' : ''),
path_prefix: custom.path_prefix,
auto_upload: custom.auto_upload,
},
};
const res = await apiFetch('/cloud-backup/config', {
method: 'PUT',
body: JSON.stringify(payload),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err?.error || 'Failed to save configuration');
}
toast.success('Custom S3 configuration saved.');
setCustom(c => ({ ...c, secret_key: '' }));
setOriginalSecretSaved(true);
loadSnapshots();
} catch (err) {
toast.error((err as Error)?.message || 'Failed to save configuration.');
} finally {
setSaving(false);
}
};
const handleTest = async () => {
setTesting(true);
try {
const res = await apiFetch('/cloud-backup/test', { method: 'POST' });
const data = await res.json().catch(() => ({}));
if (data.success) toast.success('Connection successful.');
else toast.error(data.error || 'Connection test failed.');
} catch (err) {
toast.error((err as Error)?.message || 'Connection test failed.');
} finally {
setTesting(false);
}
};
const handleProvision = async () => {
setProvisioning(true);
try {
const res = await apiFetch('/cloud-backup/provision', { method: 'POST' });
const data = await res.json().catch(() => ({}));
if (!res.ok || data?.error) throw new Error(data?.error || 'Provisioning failed.');
toast.success('Sencho Cloud Backup activated.');
setSenchoProvisioned(true);
await Promise.all([loadConfig(), loadUsage(), loadSnapshots()]);
} catch (err) {
toast.error((err as Error)?.message || 'Provisioning failed.');
} finally {
setProvisioning(false);
}
};
const handleAutoUploadToggle = async (next: boolean) => {
setCustom(c => ({ ...c, auto_upload: next }));
try {
const res = await apiFetch('/cloud-backup/config', {
method: 'PUT',
body: JSON.stringify({
provider: 'custom',
custom: { ...custom, auto_upload: next, secret_key: originalSecretSaved ? '***' : '' },
}),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err?.error || 'Failed to update auto-upload');
}
} catch (err) {
toast.error((err as Error)?.message || 'Failed to update auto-upload.');
setCustom(c => ({ ...c, auto_upload: !next }));
}
};
const confirmDelete = async () => {
if (!deleteKey) return;
try {
const encoded = btoa(deleteKey).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const res = await apiFetch(`/cloud-backup/object/${encoded}`, { method: 'DELETE' });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err?.error || 'Failed to delete cloud snapshot');
}
toast.success('Cloud snapshot deleted.');
loadSnapshots();
if (provider === 'sencho') loadUsage();
} catch (err) {
toast.error((err as Error)?.message || 'Failed to delete cloud snapshot.');
} finally {
setDeleteKey(null);
}
};
useMastheadStats(
loading
? null
: [
{
label: 'PROVIDER',
value: provider,
tone: provider === 'disabled' ? 'subtitle' : 'value',
},
...(provider === 'sencho' && usage
? [{
label: 'USED',
value: `${formatBytes(usage.used_bytes)} / ${formatBytes(usage.quota_bytes)}`,
}]
: []),
...(snapshots.length > 0
? [{ label: 'SNAPSHOTS', value: `${snapshots.length}` }]
: []),
],
);
if (loading) {
return (
<div className="space-y-3">
<Skeleton className="h-20 w-full rounded-lg" />
<Skeleton className="h-32 w-full rounded-lg" />
</div>
);
}
const usagePercent = usage && usage.quota_bytes > 0 ? Math.min(100, Math.round((usage.used_bytes / usage.quota_bytes) * 100)) : 0;
const usageColor = usagePercent >= 90 ? 'var(--destructive)' : usagePercent >= 80 ? 'var(--warning)' : 'var(--brand)';
return (
<div className="space-y-6">
<div className={PANEL_CLASS}>
<Label className="text-sm">Storage Mode</Label>
<Combobox
options={providerOptions}
value={provider}
onValueChange={handleProviderChange}
disabled={saving}
/>
<p className="text-xs text-muted-foreground">
Choose where fleet snapshots are replicated.
</p>
</div>
{isPaid && provider === 'sencho' && !senchoProvisioned && (
<div className={PANEL_CLASS}>
<div className="flex items-center gap-2">
<Cloud className="w-4 h-4 text-muted-foreground" strokeWidth={1.5} />
<span className="font-medium text-sm">Activate Sencho Cloud Backup</span>
</div>
<p className="text-xs text-muted-foreground">
Activates a 500 MB allowance backed by Cloudflare R2, scoped to this Admiral license.
</p>
<SettingsPrimaryButton size="sm" onClick={handleProvision} disabled={provisioning}>
{provisioning ? <Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" strokeWidth={1.5} /> : <Cloud className="w-3.5 h-3.5 mr-1.5" strokeWidth={1.5} />}
Activate
</SettingsPrimaryButton>
</div>
)}
{isPaid && provider === 'sencho' && senchoProvisioned && (
<div className={PANEL_CLASS}>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<CheckCircle2 className="w-4 h-4 text-success" strokeWidth={1.5} />
<span className="font-medium text-sm">Sencho Cloud Backup</span>
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={handleTest} disabled={testing}>
{testing ? <Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" strokeWidth={1.5} /> : null}
Test
</Button>
<Button size="sm" variant="ghost" onClick={handleProvision} disabled={provisioning}>
<RefreshCw className="w-3.5 h-3.5 mr-1.5" strokeWidth={1.5} />
Reprovision
</Button>
</div>
</div>
{usage && (
<div className="rounded-lg border border-glass-border px-3 py-2.5 space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="font-medium">Storage used</span>
<span className="text-stat-subtitle font-mono text-xs">
{formatBytes(usage.used_bytes)} / {formatBytes(usage.quota_bytes)} ({usage.object_count} objects)
</span>
</div>
<div className="h-1.5 rounded-full bg-muted/60 overflow-hidden">
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${usagePercent}%`,
backgroundColor: usageColor,
boxShadow: `0 0 8px ${usageColor}`,
}}
/>
</div>
</div>
)}
<div className="flex items-start gap-2 rounded-lg border border-glass-border bg-muted/30 px-3 py-2.5">
<Cloud className="w-4 h-4 text-muted-foreground shrink-0 mt-0.5" strokeWidth={1.5} />
<p className="text-xs text-muted-foreground">
Auto-upload is on for Sencho Cloud Backup. Every fleet snapshot is replicated within seconds.
</p>
</div>
</div>
)}
{provider === 'custom' && (
<div className={PANEL_CLASS}>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Cloud className="w-4 h-4 text-muted-foreground" strokeWidth={1.5} />
<span className="font-medium text-sm">Custom S3 Configuration</span>
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={handleTest} disabled={testing || saving}>
{testing ? <Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" strokeWidth={1.5} /> : null}
Test
</Button>
<SettingsPrimaryButton size="sm" onClick={handleSaveCustom} disabled={saving}>
{saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" strokeWidth={1.5} /> : null}
Save
</SettingsPrimaryButton>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Endpoint URL</Label>
<Input
placeholder="https://s3.us-east-1.amazonaws.com"
value={custom.endpoint}
onChange={e => setCustom({ ...custom, endpoint: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Region</Label>
<Input
placeholder="us-east-1"
value={custom.region}
onChange={e => setCustom({ ...custom, region: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Bucket</Label>
<Input
placeholder="my-sencho-backups"
value={custom.bucket}
onChange={e => setCustom({ ...custom, bucket: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Path Prefix</Label>
<Input
placeholder="sencho/"
value={custom.path_prefix}
onChange={e => setCustom({ ...custom, path_prefix: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Access Key ID</Label>
<Input
placeholder="AKIA..."
value={custom.access_key}
onChange={e => setCustom({ ...custom, access_key: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Secret Access Key</Label>
<Input
type="password"
placeholder={originalSecretSaved && !custom.secret_key ? '•••• saved ••••' : ''}
value={custom.secret_key}
onChange={e => setCustom({ ...custom, secret_key: e.target.value })}
/>
</div>
</div>
<div className="flex items-center justify-between rounded-lg border border-glass-border px-3 py-2.5">
<div>
<Label className="text-sm">Auto-upload</Label>
<p className="text-xs text-muted-foreground">Automatically upload every fleet snapshot to this bucket.</p>
</div>
<TogglePill checked={custom.auto_upload} onChange={handleAutoUploadToggle} />
</div>
</div>
)}
{provider !== 'disabled' && (
<div className={PANEL_CLASS}>
<div className="flex items-center justify-between">
<span className="font-medium text-sm">Cloud Snapshots</span>
<div className="flex items-center gap-1.5">
{needsPagination && (
<>
<Button variant="ghost" size="icon" className="h-6 w-6" disabled={safePage === 0} onClick={() => setPage(safePage - 1)} aria-label="Previous page">
<ChevronLeft className="h-3.5 w-3.5" strokeWidth={1.5} />
</Button>
<span className="text-xs font-mono tabular-nums text-stat-subtitle min-w-[3rem] text-center">
{safePage + 1} / {totalPages}
</span>
<Button variant="ghost" size="icon" className="h-6 w-6" disabled={safePage >= totalPages - 1} onClick={() => setPage(safePage + 1)} aria-label="Next page">
<ChevronRight className="h-3.5 w-3.5" strokeWidth={1.5} />
</Button>
</>
)}
<Button size="sm" variant="ghost" onClick={loadSnapshots}>
<RefreshCw className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
</div>
</div>
{snapshots.length === 0 ? (
<div className="flex items-start gap-2 text-xs text-muted-foreground py-2">
<CloudOff className="w-4 h-4 shrink-0 mt-0.5" strokeWidth={1.5} />
No cloud snapshots yet. The next fleet snapshot will appear here.
</div>
) : (
<ul className="space-y-1.5">
{pagedSnapshots.map(s => (
<li key={s.objectKey} className="flex items-center justify-between gap-2 rounded-md border border-glass-border px-3 py-2">
<div className="min-w-0 flex-1">
<div className="text-xs font-mono truncate">{s.objectKey.split('/').pop()}</div>
<div className="text-[10px] text-stat-subtitle font-mono">
{formatBytes(s.sizeBytes)} {s.lastModified ? `· ${new Date(s.lastModified).toLocaleString()}` : ''}
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={async () => {
const encoded = btoa(s.objectKey).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
try {
const res = await apiFetch(`/cloud-backup/object/${encoded}/download`);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err?.error || `Download failed (${res.status})`);
}
const blob = await res.blob();
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = s.objectKey.split('/').pop() || 'snapshot.tar.gz';
link.click();
URL.revokeObjectURL(link.href);
} catch (err) {
toast.error((err as Error)?.message || 'Download failed.');
}
}}
title="Download"
>
<Download className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={() => setDeleteKey(s.objectKey)}
title="Delete"
>
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
</div>
</li>
))}
</ul>
)}
</div>
)}
<ConfirmModal
open={!!deleteKey}
onOpenChange={open => !open && setDeleteKey(null)}
variant="destructive"
kicker="CLOUD · DELETE · IRREVERSIBLE"
title="Delete cloud snapshot"
confirmLabel="Delete"
onConfirm={confirmDelete}
>
<p className="text-sm text-stat-subtitle">
Permanently removes the archive from your bucket. The local SQLite copy is unaffected.
</p>
</ConfirmModal>
</div>
);
}