diff --git a/backend/app/api/connect.py b/backend/app/api/connect.py index 6d7e9406..5e810adc 100644 --- a/backend/app/api/connect.py +++ b/backend/app/api/connect.py @@ -17,3 +17,14 @@ connect_bp = Blueprint('connect', __name__) def get_status(): """Current ServerKit Cloud connection state (unpaired/paired_offline/...).""" return jsonify(connect_client.status()) + + +@connect_bp.route('/managed-profile', methods=['GET']) +@admin_required +def get_managed_profile(): + """The managed profile in force (plan 25). Read-only: the signed + ui.managed_profile command handler is the only writer, and lifting the + profile from ServerKit Cloud — never from here — is the only off switch + other than expiry or revocation.""" + from app.services import connect_managed_profile + return jsonify(connect_managed_profile.current()) diff --git a/backend/app/services/connect_client.py b/backend/app/services/connect_client.py index 48d83142..237038f2 100644 --- a/backend/app/services/connect_client.py +++ b/backend/app/services/connect_client.py @@ -432,6 +432,10 @@ def status() -> dict: def disconnect(remove_key: bool = False) -> dict: """Forget the pairing locally. Does NOT revoke anything on ServerKit Cloud.""" stop_relay_client() # no-op unless the client runs in this process + # The managed profile belongs to the pairing and leaves with it: a panel + # that is no longer connected is not managed (plan 25). + from app.services import connect_managed_profile + connect_managed_profile.clear('disconnected') removed = [] for path in (connect_file_path(), state_file_path()): if os.path.exists(path): @@ -773,6 +777,7 @@ class RelayClient: # Same terminal handling as the ws path: limited mode is # where a panel behind a code-stripping edge learns it. self._set_state('revoked', 'revoked', transport=None) + self._clear_managed_profile() logger.warning('Connect relay: device revoked on ServerKit Cloud; stopping') return ws_blocked_until = 0.0 # time to re-probe WS @@ -786,6 +791,7 @@ class RelayClient: if kind == 'revoked': self._set_state('revoked', 'revoked', transport=None) + self._clear_managed_profile() logger.warning('Connect relay: device revoked on ServerKit Cloud; stopping') return @@ -804,6 +810,16 @@ class RelayClient: self._set_state('paired_offline', reason, transport=None) self._sleep(backoff_delay(attempt)) + def _clear_managed_profile(self): + """A revoked panel is not managed: lift the profile immediately + (plan 25 "Revoked or unpaired").""" + try: + from app.services import connect_managed_profile + connect_managed_profile.clear('revoked', app=self.app) + except Exception: + logger.warning('Connect relay: could not lift the managed profile', + exc_info=True) + def _note_attempt(self): now = time.time() self._attempts_at.append(now) diff --git a/backend/app/services/connect_commands.py b/backend/app/services/connect_commands.py index 2afef238..16cb2a3e 100644 --- a/backend/app/services/connect_commands.py +++ b/backend/app/services/connect_commands.py @@ -217,4 +217,4 @@ def result_frame(cmd_id: str, state: str, result: dict = None, stream: str = Non # Importing the handler modules is what registers them. Kept at the bottom so # a handler module may import this one for @handler. -from app.services import connect_policy, connect_storage # noqa: E402,F401 +from app.services import connect_managed_profile, connect_policy, connect_storage # noqa: E402,F401 diff --git a/backend/app/services/connect_managed_profile.py b/backend/app/services/connect_managed_profile.py new file mode 100644 index 00000000..6ca38ddd --- /dev/null +++ b/backend/app/services/connect_managed_profile.py @@ -0,0 +1,264 @@ +"""The managed profile: ServerKit Cloud's signed presentation policy. + +When a customer's Managed scope says ServerKit Cloud runs the server for them, +Cloud sends one ``ui.managed_profile`` command over the same signed path as +every other command. What it carries is a *presentation* policy: which +capabilities Cloud has taken over, who holds them, and when the policy lapses. +This module is the only writer of ``managed-profile.json`` — kept next to +``connect.json`` because it belongs to the pairing and leaves with it — and +the only place that decides whether the profile currently applies. + +The rules that keep this truthful (plan 25): + +- **Hidden, never removed.** The policy changes what the panel *shows*. Every + route stays registered and every API keeps answering with the caller's + normal role; support, break-glass, exports and Cloud's own handoff to + ``/fleet`` all keep landing. +- **Expiry lifts the profile.** A panel that cannot reach Cloud past + ``expires_at`` shows everything again — a managed customer whose provider + disappears must be able to run their own server. An expired document is + kept (the UI says the profile lapsed) but holds nothing. +- **Revoked or unpaired clears it.** A panel that is no longer connected is + not managed; ``clear()`` runs on local disconnect and on relay revocation. +- **An override lifts it without deleting it.** A support session sees the + full panel until the override's end time, and the profile returns on its + own afterwards. + +Every apply and lift is written to the panel's audit log with the policy id. +""" +import json +import logging +import os +from datetime import datetime, timezone + +from app import paths +from app.services.connect_commands import handler + +logger = logging.getLogger(__name__) + +PROFILE_FILENAME = 'managed-profile.json' + +# The launch capability set (plan 25 §Vocabulary). Cloud validates against its +# own copy before signing; the panel stores what it received and the UI maps +# the ids it knows, so an id from a newer Cloud simply maps to nothing here. +CAPABILITIES = ( + 'fleet', # Servers, Agent Fleet, Fleet Proxy + 'provisioning', # Cloud Servers + 'config_templates', # Server Templates + 'updates', # panel/agent update screens and self-update + 'backups_schedule', # backup scheduling and destinations; restore stays + 'monitoring_alerts', # alert rules and channels; live charts stay + 'firewall', +) + +ACTION_APPLIED = 'connect.managed_profile_applied' +ACTION_CLEARED = 'connect.managed_profile_cleared' + + +def profile_path() -> str: + return os.path.join(paths.SERVERKIT_CONFIG_DIR, PROFILE_FILENAME) + + +def _read() -> dict: + path = profile_path() + if not os.path.exists(path): + return {} + try: + with open(path, 'r', encoding='utf-8') as f: + data = json.load(f) + return data if isinstance(data, dict) else {} + except (OSError, ValueError) as exc: + logger.warning('Could not read %s: %s', path, exc) + return {} + + +def _write(payload: dict): + path = profile_path() + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, 'w', encoding='utf-8') as f: + json.dump(payload, f, indent=2) + f.write('\n') + if os.name != 'nt': + os.chmod(path, 0o600) + + +def _parse_dt(value): + if not value: + return None + try: + dt = datetime.fromisoformat(str(value).replace('Z', '+00:00')) + except ValueError: + return None + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt + + +def _audit(action: str, details: dict, app=None): + """Best effort and never in the policy's way: a profile that applied but + could not be audited still applies, and the warning says why.""" + try: + if app is None: + from flask import has_app_context + if not has_app_context(): + return + from app.services.audit_service import AuditService + def go(): + AuditService.log(action, target_type='connect', + target_id=details.get('policy_id'), details=details) + if app is not None: + with app.app_context(): + go() + else: + go() + except Exception: + logger.warning('Managed profile: could not write the audit entry', exc_info=True) + + +def clear(reason: str, app=None, policy_id: str = None) -> bool: + """Remove the profile document. Returns True when one was there to lift. + + Every lift is audited with the policy id that was in force and the reason + ('cleared' from Cloud, 'revoked', 'disconnected').""" + doc = _read() + path = profile_path() + if os.path.exists(path): + try: + os.unlink(path) + except OSError as exc: + logger.warning('Could not remove %s: %s', path, exc) + return False + if doc: + _audit(ACTION_CLEARED, { + 'policy_id': doc.get('policy_id') or policy_id, + 'reason': reason, + 'capabilities': doc.get('capabilities') or [], + }, app=app) + return bool(doc) + + +def current(now: datetime = None) -> dict: + """The effective profile for the API and the UI. + + ``active`` is the single question the sidebar, pages and in-page controls + ask. An expired policy or a live override both answer False — the full + panel shows — while the document's detail still comes back so Settings + can say exactly what lapsed or who lifted it and until when. + """ + doc = _read() + if not doc: + return { + 'profile': 'none', + 'active': False, + 'capabilities': [], + 'held_by': None, + 'since': None, + 'expires_at': None, + 'support_url': None, + 'policy_id': None, + 'applied_at': None, + 'expired': False, + 'override': None, + } + now = now or datetime.now(timezone.utc) + expires_at = _parse_dt(doc.get('expires_at')) + expired = expires_at is None or now >= expires_at + override = doc.get('override') if isinstance(doc.get('override'), dict) else None + override_until = _parse_dt((override or {}).get('until')) + override_active = override is not None and override_until is not None and now < override_until + capabilities = [c for c in (doc.get('capabilities') or []) if isinstance(c, str)] + active = bool(capabilities) and not expired and not override_active + return { + 'profile': doc.get('profile') or 'managed', + 'active': active, + # The UI only ever reads held capabilities when active is True; the + # full list stays in the document either way for Settings. + 'capabilities': capabilities if active else [], + 'held_capabilities': capabilities, + 'held_by': doc.get('held_by'), + 'since': doc.get('since'), + 'expires_at': doc.get('expires_at'), + 'support_url': doc.get('support_url'), + 'policy_id': doc.get('policy_id'), + 'applied_at': doc.get('applied_at'), + 'expired': expired, + 'override': {'until': override.get('until'), 'reason': override.get('reason')} + if override_active else None, + } + + +def _validate(args: dict) -> dict: + """The document to store, or ValueError with the sentence Cloud shows.""" + policy_id = str(args.get('policy_id') or '').strip() + if not policy_id: + raise ValueError('The policy carried no id, so it was not applied.') + profile = str(args.get('profile') or 'managed') + if profile == 'none': + return {'policy_id': policy_id, 'profile': 'none', 'capabilities': []} + if profile != 'managed': + raise ValueError(f'Unknown managed profile {profile!r}.') + capabilities = args.get('capabilities') + if not isinstance(capabilities, list) or not all(isinstance(c, str) for c in capabilities): + raise ValueError('The policy capabilities were not a list of names.') + expires_at = _parse_dt(args.get('expires_at')) + if expires_at is None: + # A policy without an end never lifts itself, which is exactly the + # failure the expiry rule exists to prevent. Refuse it rather than + # store it. + raise ValueError('The policy carried no expiry, so it was not applied.') + override = args.get('override') + if override is not None: + if not isinstance(override, dict) or _parse_dt(override.get('until')) is None: + raise ValueError('The policy override carried no end time, so it was not applied.') + override = {'until': override.get('until'), + 'reason': str(override.get('reason') or '')[:500]} + return { + 'policy_id': policy_id, + 'profile': 'managed', + 'capabilities': sorted({c.strip() for c in capabilities if c.strip()}), + 'held_by': str(args.get('held_by') or 'ServerKit Cloud')[:200], + 'since': args.get('since'), + 'expires_at': expires_at.isoformat(), + 'support_url': (str(args.get('support_url'))[:500] if args.get('support_url') else None), + 'override': override, + } + + +@handler('ui.managed_profile') +def apply_profile(args: dict, app=None) -> dict: + """Apply (or clear) the managed profile Cloud signed. + + The result output is a small JSON document carrying the policy id back as + the acknowledgement; Cloud matches it against what it sent and re-sends + once when they disagree.""" + try: + doc = _validate(args or {}) + except ValueError as exc: + return {'ok': False, 'code': 400, 'summary': str(exc)} + + if doc['profile'] == 'none' or not doc['capabilities']: + had = clear('cleared', app=app, policy_id=doc['policy_id']) + summary = ('The managed profile was lifted; the full panel is back.' + if had else 'No managed profile was in force; nothing changed.') + return {'ok': True, 'summary': summary, + 'output': json.dumps({'policy_id': doc['policy_id'], 'applied': False})} + + doc['applied_at'] = datetime.now(timezone.utc).isoformat() + try: + _write(doc) + except OSError as exc: + return {'ok': False, 'code': 500, + 'summary': f'The profile could not be stored ({exc}), so nothing was applied.'} + _audit(ACTION_APPLIED, { + 'policy_id': doc['policy_id'], + 'capabilities': doc['capabilities'], + 'held_by': doc['held_by'], + 'since': doc['since'], + 'expires_at': doc['expires_at'], + 'override': doc['override'], + }, app=app) + return {'ok': True, + 'summary': f"Managed profile applied: {len(doc['capabilities'])} " + f"capabilit{'y is' if len(doc['capabilities']) == 1 else 'ies are'} " + f"held by {doc['held_by']} until {doc['expires_at']}.", + 'output': json.dumps({'policy_id': doc['policy_id'], 'applied': True})} diff --git a/backend/tests/test_connect_managed_profile.py b/backend/tests/test_connect_managed_profile.py new file mode 100644 index 00000000..bb96d7d1 --- /dev/null +++ b/backend/tests/test_connect_managed_profile.py @@ -0,0 +1,137 @@ +"""Managed profile (plan 25 M1): apply, expiry, override, clear, audit.""" +import json +from datetime import datetime, timedelta, timezone + +import pytest + +from app.services import connect_managed_profile as mp + + +@pytest.fixture(autouse=True) +def profile_dir(tmp_path, monkeypatch): + monkeypatch.setattr(mp.paths, 'SERVERKIT_CONFIG_DIR', str(tmp_path)) + yield tmp_path + + +def _args(**over): + base = { + 'policy_id': 'mpol_test1', + 'profile': 'managed', + 'capabilities': ['fleet', 'updates'], + 'held_by': 'ServerKit Cloud', + 'since': '2026-10-01T00:00:00Z', + 'expires_at': (datetime.now(timezone.utc) + timedelta(days=7)).isoformat(), + 'support_url': 'https://app.serverkit.ai/orgs/acme/servers/dev_1', + 'override': None, + } + base.update(over) + return base + + +def _read_doc(profile_dir): + with open(profile_dir / 'managed-profile.json', 'r', encoding='utf-8') as f: + return json.load(f) + + +def test_apply_stores_document_and_acks_policy_id(profile_dir): + out = mp.apply_profile(_args()) + assert out['ok'] is True + doc = _read_doc(profile_dir) + assert doc['policy_id'] == 'mpol_test1' + assert doc['capabilities'] == ['fleet', 'updates'] + assert doc['override'] is None + assert doc['applied_at'] + ack = json.loads(out['output']) + assert ack == {'policy_id': 'mpol_test1', 'applied': True} + + +def test_current_active_profile(): + mp.apply_profile(_args()) + state = mp.current() + assert state['active'] is True + assert state['capabilities'] == ['fleet', 'updates'] + assert state['held_by'] == 'ServerKit Cloud' + assert state['expired'] is False + assert state['override'] is None + + +def test_no_profile_is_inactive(): + state = mp.current() + assert state['active'] is False + assert state['profile'] == 'none' + assert state['capabilities'] == [] + + +def test_expired_policy_holds_nothing_but_stays_visible(): + mp.apply_profile(_args(expires_at=(datetime.now(timezone.utc) + - timedelta(hours=1)).isoformat())) + state = mp.current() + assert state['active'] is False + assert state['capabilities'] == [] + assert state['expired'] is True + # The document's detail survives so Settings can say what lapsed. + assert state['held_capabilities'] == ['fleet', 'updates'] + assert state['policy_id'] == 'mpol_test1' + + +def test_override_lifts_profile_until_its_end(): + until = (datetime.now(timezone.utc) + timedelta(hours=2)).isoformat() + mp.apply_profile(_args(override={'until': until, 'reason': 'support session'})) + state = mp.current() + assert state['active'] is False + assert state['capabilities'] == [] + assert state['override'] == {'until': until, 'reason': 'support session'} + + +def test_elapsed_override_returns_the_profile(): + until = (datetime.now(timezone.utc) - timedelta(minutes=5)).isoformat() + mp.apply_profile(_args(override={'until': until, 'reason': 'support session'})) + state = mp.current() + assert state['active'] is True + assert state['capabilities'] == ['fleet', 'updates'] + assert state['override'] is None + + +def test_profile_none_clears_and_acks(profile_dir): + mp.apply_profile(_args()) + out = mp.apply_profile(_args(policy_id='mpol_clear1', profile='none', + capabilities=[])) + assert out['ok'] is True + assert 'lifted' in out['summary'] + assert not (profile_dir / 'managed-profile.json').exists() + assert mp.current()['active'] is False + ack = json.loads(out['output']) + assert ack == {'policy_id': 'mpol_clear1', 'applied': False} + + +def test_empty_capability_list_clears(): + mp.apply_profile(_args()) + out = mp.apply_profile(_args(capabilities=[])) + assert out['ok'] is True + assert mp.current()['active'] is False + + +def test_policy_without_expiry_is_refused(profile_dir): + out = mp.apply_profile(_args(expires_at=None)) + assert out['ok'] is False + assert 'expiry' in out['summary'] + assert not (profile_dir / 'managed-profile.json').exists() + + +def test_policy_without_id_is_refused(profile_dir): + out = mp.apply_profile(_args(policy_id='')) + assert out['ok'] is False + assert not (profile_dir / 'managed-profile.json').exists() + + +def test_override_without_end_is_refused(profile_dir): + out = mp.apply_profile(_args(override={'reason': 'support session'})) + assert out['ok'] is False + assert not (profile_dir / 'managed-profile.json').exists() + + +def test_clear_reports_whether_one_was_in_force(): + assert mp.clear('revoked') is False + mp.apply_profile(_args()) + assert mp.clear('revoked') is True + assert mp.current()['active'] is False diff --git a/frontend/src/components/ManagedCard.jsx b/frontend/src/components/ManagedCard.jsx new file mode 100644 index 00000000..183ac213 --- /dev/null +++ b/frontend/src/components/ManagedCard.jsx @@ -0,0 +1,61 @@ +import { CloudCog, ExternalLink } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import useLabel from '../i18n/labels'; +import { MANAGED_CAPABILITY_MAP } from './sidebarItems'; + +// The managed card (plan 25): what a held capability's page shows instead of +// its content. It names the capability, who holds it and since when, and links +// to the server's page in ServerKit Cloud through the support URL the signed +// policy carried. It never claims the page is gone — hidden is not removed: +// the route still renders, the API still answers, and support, break-glass and +// exports keep working. +// +// `compact` is the card in miniature, for the spot an in-page control used to +// occupy (the self-update section in Settings › About, backup scheduling). +export default function ManagedCard({ capability, profile, compact = false }) { + const { t } = useTranslation(); + const label_ = useLabel(); + const entry = MANAGED_CAPABILITY_MAP[capability]; + const label = entry ? label_(entry) : capability; + const heldBy = profile?.held_by || 'ServerKit Cloud'; + const since = profile?.since + ? new Date(profile.since).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }) + : null; + + return ( +
+
+ +
+

+ {t('managed.card.title', '{{label}} is managed by {{heldBy}}', { label, heldBy })} +

+

+ {since + ? t('managed.card.bodySince', 'ServerKit Cloud took this over on {{date}} as part of your Managed service. The page is hidden, not removed — its API still answers and everything returns the moment the Managed scope ends.', { date: since }) + : t('managed.card.body', 'ServerKit Cloud takes care of this as part of your Managed service. The page is hidden, not removed — its API still answers and everything returns the moment the Managed scope ends.')} + {capability === 'updates' && ( + <> {t('managed.card.updatesNote', 'The panel still updates itself; only the update screens are hidden.')} + )} +

+ {profile?.support_url && ( +
+ + + {t('managed.card.openCloud', 'Open in ServerKit Cloud')} + +
+ )} +
+ ); +} diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index 26b12694..732661f2 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -12,6 +12,7 @@ import { useContributions } from '../plugins/contributions'; import { sanitizeSvgInner } from '../utils/sanitizeSvg'; import useModules from '../hooks/useModules'; import useDevMode from '../hooks/useDevMode'; +import { useManagedProfile } from '../contexts/useManagedProfile'; import QuickCreate from './QuickCreate'; import { useWorkspace } from '../contexts/useWorkspace.js'; import { Button as SharedButton } from '@/components/ui/button'; @@ -144,6 +145,9 @@ const Sidebar = ({ mobileOpen = false, isMobile = false, onMobileClose = () => { // Developer-only items (Test Sandbox) — same source the route guard reads. const devMode = useDevMode(); + // Sidebar items ServerKit Cloud holds under the managed profile (plan 25). + const { hiddenSidebarIds: managedHidden } = useManagedProfile(); + const conditions = { wpInstalled, gpuAvailable, wordpressEnabled, devMode }; const currentPreset = user?.sidebar_config?.preset || 'recommended'; const [manualExpanded, setManualExpanded] = useState({}); @@ -209,8 +213,15 @@ const Sidebar = ({ mobileOpen = false, isMobile = false, onMobileClose = () => { // Apply workspace-level nav permissions if an active workspace is set // and it defines a nav map. This lets a workspace restrict which sidebar // items its members see based on their effective workspace role. - return applyWorkspaceNavPermissions(items, activeWorkspace, user); - }, [pluginNav, pluginTabs, wpInstalled, gpuAvailable, wordpressEnabled, devMode, user, hasPermission, activeWorkspace]); + items = applyWorkspaceNavPermissions(items, activeWorkspace, user); + // The managed profile (plan 25): capabilities ServerKit Cloud holds + // remove their sidebar items from every preset, the same way a + // workspace nav map removes them. + if (managedHidden.size > 0) { + items = items.filter((item) => !managedHidden.has(item.id)); + } + return items; + }, [pluginNav, pluginTabs, wpInstalled, gpuAvailable, wordpressEnabled, devMode, user, hasPermission, activeWorkspace, managedHidden]); // Group visible items by category const groupedItems = useMemo(() => { diff --git a/frontend/src/components/managedCapabilities.js b/frontend/src/components/managedCapabilities.js new file mode 100644 index 00000000..d3da71aa --- /dev/null +++ b/frontend/src/components/managedCapabilities.js @@ -0,0 +1,118 @@ +// Managed profile (ServerKit Cloud, plan 25) +// +// When ServerKit Cloud holds a Managed scope for this server it sends a signed +// presentation policy naming the capabilities it has taken over. This ONE +// table is the whole mapping from a held capability to what the panel stops +// showing: the sidebar items it removes, the route prefixes whose pages render +// the managed card instead of their content, and the in-page controls +// elsewhere that collapse to the card in miniature. Hidden is not removed — +// every route stays registered and every API keeps answering. +// +// This lives in its own module (re-exported from sidebarItems.js, which is +// where the mapping is read alongside the items it hides) because it must +// stay dependency-free: the drift guard in +// routes/__tests__/managedProfile.test.mjs imports it under plain node, and +// sidebarItems.js pulls in JSX tab modules node cannot parse. +export const MANAGED_CAPABILITY_MAP = { + // Servers, Agent Fleet and Fleet Proxy — the whole Servers top bar, + // including per-server detail pages under /servers/:id. + fleet: { + labelKey: 'managed.capability.fleet', + label: 'Server fleet', + noteKey: 'managed.capabilityNote.fleet', + note: 'Servers, Agent Fleet and Fleet Proxy', + sidebarIds: ['servers'], + routePrefixes: ['/servers', '/fleet', '/fleet-proxy'], + // "Deploy to server" in Templates targets a fleet the customer no + // longer operates. + controls: ['templates-deploy'], + }, + // Cloud Servers (the serverkit-cloud-provision tab). + provisioning: { + labelKey: 'managed.capability.provisioning', + label: 'Server provisioning', + noteKey: 'managed.capabilityNote.provisioning', + note: 'Cloud Servers', + sidebarIds: [], + routePrefixes: ['/cloud'], + controls: [], + }, + // Server Templates (Config Templates under the Servers top bar). + config_templates: { + labelKey: 'managed.capability.config_templates', + label: 'Server templates', + noteKey: 'managed.capabilityNote.config_templates', + note: 'Config Templates', + sidebarIds: [], + routePrefixes: ['/server-templates'], + controls: [], + }, + // Panel/agent update screens and self-update. The self-update section in + // Settings › About collapses to the card in miniature. (The panel's + // self-update still runs; the card says so until plan 08's rings own it.) + updates: { + labelKey: 'managed.capability.updates', + label: 'Updates', + noteKey: 'managed.capabilityNote.updates', + note: 'Panel and agent update screens and self-update', + sidebarIds: [], + routePrefixes: [], + controls: ['self-update'], + }, + // Backup scheduling and destinations. The Backups page stays — local + // restore is the customer's — but its scheduling sections collapse. + backups_schedule: { + labelKey: 'managed.capability.backups_schedule', + label: 'Backup scheduling', + noteKey: 'managed.capabilityNote.backups_schedule', + note: 'Backup schedules and destinations; local restore stays with you', + sidebarIds: [], + routePrefixes: [], + controls: ['backup-scheduling'], + }, + // Alert rules and channels; the live charts stay. + monitoring_alerts: { + labelKey: 'managed.capability.monitoring_alerts', + label: 'Alert rules', + noteKey: 'managed.capabilityNote.monitoring_alerts', + note: 'Alert rules and channels; the live charts stay', + sidebarIds: [], + routePrefixes: ['/monitoring/rules'], + controls: [], + }, + firewall: { + labelKey: 'managed.capability.firewall', + label: 'Firewall', + noteKey: 'managed.capabilityNote.firewall', + note: 'Firewall management', + sidebarIds: [], + routePrefixes: ['/security/firewall'], + controls: [], + }, +}; + +// Sidebar item ids a held capability set removes. alwaysVisible items are +// never in the map, so this needs no alwaysVisible carve-out. +export function managedSidebarIds(capabilities = []) { + const ids = new Set(); + for (const cap of capabilities) { + for (const id of MANAGED_CAPABILITY_MAP[cap]?.sidebarIds || []) ids.add(id); + } + return ids; +} + +// The capability whose pages a path falls under, or null. Segment-boundary +// matching: '/fleet' matches '/fleet' and '/fleet/3', never '/fleet-proxy'. +export function managedCapabilityForPath(pathname, capabilities = []) { + for (const cap of capabilities) { + for (const prefix of MANAGED_CAPABILITY_MAP[cap]?.routePrefixes || []) { + if (pathname === prefix || pathname.startsWith(prefix + '/')) return cap; + } + } + return null; +} + +// Does a held capability set cover a named in-page control? +export function managedControlHeld(control, capabilities = []) { + return capabilities.some((cap) => (MANAGED_CAPABILITY_MAP[cap]?.controls || []).includes(control)); +} diff --git a/frontend/src/components/settings/AboutTab.jsx b/frontend/src/components/settings/AboutTab.jsx index 4943eef2..3181529b 100644 --- a/frontend/src/components/settings/AboutTab.jsx +++ b/frontend/src/components/settings/AboutTab.jsx @@ -7,6 +7,8 @@ import { import ServerKitLogo from '../ServerKitLogo'; import { Button } from '@/components/ui/button'; import useSettingFocus from '../../hooks/useSettingFocus'; +import { useManagedProfile } from '../../contexts/useManagedProfile'; +import ManagedCard from '../ManagedCard'; import { useTranslation } from 'react-i18next'; const STAR_PROMPT_KEY = 'serverkit-star-prompt-dismissed'; @@ -28,6 +30,11 @@ const AboutTab = () => { const [updateError, setUpdateError] = useState(null); const cancelledRef = useRef(false); const register = useSettingFocus(); + // Updates held by ServerKit Cloud (plan 25): the self-update surface is + // the managed card in miniature. The panel still updates itself — the + // card says so. + const { isControlHeld, profile: managedProfile } = useManagedProfile(); + const updatesManaged = isControlHeld('self-update'); useEffect(() => () => { cancelledRef.current = true; }, []); @@ -141,7 +148,9 @@ const AboutTab = () => {

- {!updateInfo ? ( + {updatesManaged ? ( + + ) : !updateInfo ? ( + + )}
); diff --git a/frontend/src/routes/__tests__/managedProfile.test.mjs b/frontend/src/routes/__tests__/managedProfile.test.mjs new file mode 100644 index 00000000..a16640d1 --- /dev/null +++ b/frontend/src/routes/__tests__/managedProfile.test.mjs @@ -0,0 +1,110 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { + MANAGED_CAPABILITY_MAP, + managedCapabilityForPath, + managedControlHeld, + managedSidebarIds, +} from '../../components/managedCapabilities.js'; +import { CORE_ROUTES } from '../routeManifest.js'; + +// The drift guard for the managed capability map (plan 25 §Risks): the mapping +// from capability to sidebar ids, routes and in-page controls lives in ONE +// table (components/managedCapabilities.js, re-exported from sidebarItems.js), +// and this test is what keeps it true as pages move. A failure here means the +// map points at something that no longer exists — fix the map, not this test, +// unless the page itself went away deliberately. +// +// sidebarItems.js itself cannot be imported under plain node (it pulls in JSX +// tab modules), so the sidebar ids and alwaysVisible flags are read from its +// source text: the SIDEBAR_ITEMS array is the section between its declaration +// and the ADVANCED_ITEM_IDS export, and each item is one `id: '...'` entry. + +const sidebarItemsPath = fileURLToPath(new URL('../../components/sidebarItems.js', import.meta.url)); +const sidebarSource = await readFile(sidebarItemsPath, 'utf8'); +const itemsSection = sidebarSource.slice( + sidebarSource.indexOf('export const SIDEBAR_ITEMS'), + sidebarSource.indexOf('export const ADVANCED_ITEM_IDS'), +); + +const sidebarItems = []; +for (const match of itemsSection.matchAll(/^\s+id: '([a-z0-9-]+)',$/gm)) { + const start = match.index; + const next = itemsSection.indexOf("\n id: '", start + 1); + const block = itemsSection.slice(start, next === -1 ? undefined : next); + sidebarItems.push({ id: match[1], alwaysVisible: /alwaysVisible:\s*true/.test(block) }); +} +const sidebarIds = new Set(sidebarItems.map((item) => item.id)); + +// Match a route manifest path against a concrete pathname: ':param' segments +// match one segment each. +function manifestMatches(pattern, pathname) { + const wanted = pattern.split('/').filter(Boolean); + const got = pathname.split('/').filter(Boolean); + if (wanted.length !== got.length) return false; + return wanted.every((segment, i) => segment.startsWith(':') || segment === got[i]); +} + +// Route prefixes that are real but contributed by builtin extensions rather +// than the core manifest (the Cloud Servers tab comes from +// serverkit-cloud-provision). +const EXTENSION_ROUTES = ['/cloud']; + +function routeExists(prefix) { + if (EXTENSION_ROUTES.includes(prefix)) return true; + return CORE_ROUTES.some((route) => manifestMatches(route.path, prefix)); +} + +test('sidebarItems.js was parsed into items', () => { + assert.ok(sidebarIds.has('servers') && sidebarIds.has('dashboard'), + `expected sidebar items, got: ${[...sidebarIds].join(', ')}`); +}); + +test('every mapped sidebar id exists and is hideable', () => { + for (const [capability, entry] of Object.entries(MANAGED_CAPABILITY_MAP)) { + for (const id of entry.sidebarIds) { + const item = sidebarItems.find((candidate) => candidate.id === id); + assert.ok(item, `${capability}: sidebar id ${id} is not in SIDEBAR_ITEMS`); + assert.ok(!item.alwaysVisible, `${capability}: ${id} is alwaysVisible and cannot be hidden`); + } + } +}); + +test('every mapped route prefix is a real route', () => { + for (const [capability, entry] of Object.entries(MANAGED_CAPABILITY_MAP)) { + for (const prefix of entry.routePrefixes) { + assert.ok(routeExists(prefix), `${capability}: no route matches ${prefix}`); + } + } +}); + +test('route prefix matching respects segment boundaries', () => { + assert.equal(managedCapabilityForPath('/fleet', ['fleet']), 'fleet'); + assert.equal(managedCapabilityForPath('/servers/3', ['fleet']), 'fleet'); + assert.equal(managedCapabilityForPath('/fleet-proxy', ['provisioning']), null); + // /fleet-proxy is its own fleet route, not a sub-path of /fleet. + assert.equal(managedCapabilityForPath('/fleet-proxy', ['fleet']), 'fleet'); + assert.equal(managedCapabilityForPath('/monitoring', ['monitoring_alerts']), null); + assert.equal(managedCapabilityForPath('/monitoring/rules', ['monitoring_alerts']), 'monitoring_alerts'); + assert.equal(managedCapabilityForPath('/backups', ['backups_schedule']), null, + 'the backups page stays; only its scheduling controls collapse'); +}); + +test('helpers derive from the one table', () => { + assert.deepEqual([...managedSidebarIds(['fleet'])], ['servers']); + assert.deepEqual([...managedSidebarIds(['updates'])], []); + assert.equal(managedControlHeld('templates-deploy', ['fleet']), true); + assert.equal(managedControlHeld('templates-deploy', []), false); + assert.equal(managedControlHeld('backup-scheduling', ['backups_schedule']), true); + assert.equal(managedControlHeld('self-update', ['updates']), true); +}); + +test('the launch capability set matches the plan', () => { + assert.deepEqual(Object.keys(MANAGED_CAPABILITY_MAP).sort(), [ + 'backups_schedule', 'config_templates', 'firewall', 'fleet', + 'monitoring_alerts', 'provisioning', 'updates', + ]); +}); diff --git a/frontend/src/services/api/system.js b/frontend/src/services/api/system.js index 2efa258b..905f9f30 100644 --- a/frontend/src/services/api/system.js +++ b/frontend/src/services/api/system.js @@ -121,6 +121,16 @@ export async function checkUpdate() { return this.request('/system/check-update'); } +// ServerKit Cloud connect state and the managed presentation policy (plan 25). +// Both are admin-only on the backend; callers treat a refusal as "no profile". +export async function getConnectStatus() { + return this.request('/connect/status'); +} + +export async function getManagedProfile() { + return this.request('/connect/managed-profile'); +} + export async function getPanelUpdateStatus() { return this.request('/system/update'); } diff --git a/frontend/src/styles/components/_managed-card.scss b/frontend/src/styles/components/_managed-card.scss new file mode 100644 index 00000000..c64bbfc7 --- /dev/null +++ b/frontend/src/styles/components/_managed-card.scss @@ -0,0 +1,85 @@ +// Managed card + lapsed-profile banner (plan 25). What a held capability's +// page shows instead of its content, and the one-line notice when a policy +// lapsed and the full panel returned. + +.managed-card { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + gap: $spacing-md; + padding: $spacing-xl $spacing-lg; + max-width: 560px; + margin: $spacing-xl auto; + background: $bg-card; + border: 1px solid $border-color; + border-radius: $radius-xl; + + &__icon { + color: $primary-color; + display: flex; + } + + &__title { + margin: 0; + font-size: $font-size-xl; + font-weight: 600; + } + + &__body { + margin: 0; + color: $text-secondary; + line-height: 1.55; + } + + &__action { + margin-top: $spacing-sm; + } + + &--compact { + margin: $spacing-md 0; + padding: $spacing-lg; + gap: $spacing-sm; + + .managed-card__title { + font-size: $font-size-lg; + } + } +} + +.managed-lapsed-banner { + display: flex; + align-items: center; + gap: $spacing-sm; + margin: 0 0 $spacing-md; + padding: $spacing-sm $spacing-md; + background: rgba($warning-color, 0.12); + border: 1px solid rgba($warning-color, 0.35); + border-radius: $radius-md; + color: $text-primary; + font-size: $font-size-sm; +} + +.managed-spec-list { + display: flex; + flex-direction: column; + gap: $spacing-xs; + margin: $spacing-sm 0; +} + +.managed-spec-row { + display: flex; + justify-content: space-between; + gap: $spacing-md; + font-size: $font-size-sm; +} + +.managed-spec-key { + color: $text-secondary; +} + +.managed-spec-val { + overflow-wrap: anywhere; + text-align: right; +} diff --git a/frontend/src/styles/main.scss b/frontend/src/styles/main.scss index b85294d9..e676f0a2 100644 --- a/frontend/src/styles/main.scss +++ b/frontend/src/styles/main.scss @@ -39,6 +39,7 @@ @import 'components/_forms'; @import 'components/_form-affordances'; @import 'components/_badges'; +@import 'components/_managed-card'; @import 'components/_tabs'; @import 'components/_lists'; @import 'components/_services';