feat(connect): apply the managed presentation profile (plan 25)

Panel side of the managed profile: the panel receives a presentation
profile from Cloud over the existing command channel, stores it,
acknowledges it, and hides or simplifies fleet-owner-only surfaces
until the profile is lifted or the link is revoked.

- ui.managed_profile command handler with managed-profile.json
  storage, audit entries, and ack-by-output; profile is cleared on
  disconnect and on consent revoke
- GET /api/v1/connect/managed-profile for the local UI
- ManagedProfileContext with capability-driven filtering of sidebar
  items, command palette actions, and sidebar settings; ManagedCard
  explains the managed state
- In-page collapses for Templates deploy, About self-update, and
  Backup schedules/storage (restore stays available)
- Settings gains a ServerKit Cloud tab showing link and profile state

Local verification: 153 backend tests pass, 198 frontend node tests
pass, lint clean, build ok. No live policy has been pushed to a real
panel yet.
This commit is contained in:
Juan Denis committed 2026-09-13 03:50:02 -04:00
1 parent f4c641032a
commit d866de5439
26 files changed
+1224 -16

No files matched your search

+11
View File
@@ -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())
+16
View File
@@ -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)
+1 -1
View File
@@ -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
@@ -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})}
@@ -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
+61
View File
@@ -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 (
<div
className={`managed-card${compact ? ' managed-card--compact' : ''}`}
data-testid={compact ? `managed-card-${capability}` : 'managed-card'}
data-capability={capability}
>
<div className="managed-card__icon">
<CloudCog size={compact ? 24 : 48} />
</div>
<h3 className="managed-card__title">
{t('managed.card.title', '{{label}} is managed by {{heldBy}}', { label, heldBy })}
</h3>
<p className="managed-card__body">
{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.')}</>
)}
</p>
{profile?.support_url && (
<div className="managed-card__action">
<a
className="btn btn-primary"
href={profile.support_url}
target="_blank"
rel="noreferrer"
data-testid="managed-card-open-cloud"
>
<ExternalLink size={15} />
{t('managed.card.openCloud', 'Open in ServerKit Cloud')}
</a>
</div>
)}
</div>
);
}
+13 -2
View File
@@ -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(() => {
@@ -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));
}
+10 -1
View File
@@ -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 = () => {
</p>
<div className="update-check">
{!updateInfo ? (
{updatesManaged ? (
<ManagedCard capability="updates" profile={managedProfile} compact />
) : !updateInfo ? (
<Button
variant="outline"
size="sm"
@@ -0,0 +1,133 @@
import { useEffect, useState } from 'react';
import { CloudCog, ExternalLink, PlugZap } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import api from '../../services/api';
import { useManagedProfile } from '../../contexts/useManagedProfile';
import useLabel from '../../i18n/labels';
import { MANAGED_CAPABILITY_MAP } from '../sidebarItems';
// Settings › ServerKit Cloud (plan 25 M3): the one place the customer reads
// what ServerKit Cloud holds for them — the connect state, and the managed
// profile with the responsibilities Cloud took over, since when, until when,
// and any active support override. Nothing here can turn the profile off;
// that is done in ServerKit Cloud, and the page says so.
const CloudTab = () => {
const { t } = useTranslation();
const label_ = useLabel();
const { profile, lapsed } = useManagedProfile();
const [connect, setConnect] = useState(null);
useEffect(() => {
api.getConnectStatus().then(setConnect).catch(() => setConnect(null));
}, []);
const held = profile?.held_capabilities || [];
const fmt = (iso) => (iso
? new Date(iso).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })
: null);
return (
<div className="settings-section" data-testid="settings-cloud-tab">
<div className="section-header">
<h2>{t('app.settings.cloud.title', 'ServerKit Cloud')}</h2>
<p>{t('app.settings.cloud.subtitle', 'What ServerKit Cloud does for this server, and what it has taken over')}</p>
</div>
<div className="settings-card">
<h3>
<PlugZap size={16} /> {t('app.settings.cloud.connection', 'Connection')}
</h3>
{connect ? (
<div className="managed-spec-list">
<div className="managed-spec-row">
<span className="managed-spec-key">{t('app.settings.cloud.state', 'State')}</span>
<span className="managed-spec-val" data-testid="cloud-connect-state">{connect.state}</span>
</div>
{connect.org_slug && (
<div className="managed-spec-row">
<span className="managed-spec-key">{t('app.settings.cloud.organisation', 'Organisation')}</span>
<span className="managed-spec-val">{connect.org_slug}</span>
</div>
)}
{connect.name && (
<div className="managed-spec-row">
<span className="managed-spec-key">{t('app.settings.cloud.serverName', 'Server name in Cloud')}</span>
<span className="managed-spec-val">{connect.name}</span>
</div>
)}
</div>
) : (
<p className="description">{t('app.settings.cloud.notPaired', 'This panel is not connected to ServerKit Cloud.')}</p>
)}
</div>
<div className="settings-card" data-testid="managed-profile-section">
<h3>
<CloudCog size={16} /> {t('app.settings.cloud.managedProfile', 'Managed profile')}
</h3>
{!profile && (
<p className="description">
{t('app.settings.cloud.noProfile', 'No managed profile. ServerKit Cloud has not taken over any part of this panel — everything you see is yours to run.')}
</p>
)}
{profile && (
<>
{lapsed && (
<div className="alert alert-warning" role="status">
{t('app.settings.cloud.lapsed', 'The profile below lapsed — Cloud has been unreachable past its expiry, so the full panel is back. It returns if Cloud reconnects and re-sends it.')}
</div>
)}
{profile.override && (
<div className="alert alert-info" role="status" data-testid="managed-profile-override">
{profile.override.reason
? t('app.settings.cloud.overrideReason', 'Lifted for a support session until {{until}} — {{reason}}. The profile returns on its own afterwards.', { until: fmt(profile.override.until), reason: profile.override.reason })
: t('app.settings.cloud.override', 'Lifted for a support session until {{until}}. The profile returns on its own afterwards.', { until: fmt(profile.override.until) })}
</div>
)}
<p className="description">
{t('app.settings.cloud.profileIntro', '{{heldBy}} took over the responsibilities below on {{since}}. Pages they cover show a managed card instead of their tools; every route and API keeps working.', { heldBy: profile.held_by || 'ServerKit Cloud', since: fmt(profile.since) || '—' })}
</p>
<ul className="feature-list" data-testid="managed-profile-capabilities">
{held.map((cap) => {
const entry = MANAGED_CAPABILITY_MAP[cap];
return (
<li key={cap}>
<CloudCog size={16} />
<span>
<strong>{entry ? label_(entry) : cap}</strong>
{entry?.note ? ` — ${label_(entry, 'note')}` : ''}
</span>
</li>
);
})}
</ul>
<div className="managed-spec-list">
<div className="managed-spec-row">
<span className="managed-spec-key">{t('app.settings.cloud.policyExpires', 'Policy expires')}</span>
<span className="managed-spec-val">{fmt(profile.expires_at) || '—'}</span>
</div>
<div className="managed-spec-row">
<span className="managed-spec-key">{t('app.settings.cloud.policyId', 'Policy')}</span>
<span className="managed-spec-val"><code>{profile.policy_id}</code></span>
</div>
</div>
{profile.support_url && (
<p>
<a className="btn btn-secondary btn-sm" href={profile.support_url}
target="_blank" rel="noreferrer" data-testid="managed-profile-open-cloud">
<ExternalLink size={14} />
{t('app.settings.cloud.openInCloud', 'Open this server in ServerKit Cloud')}
</a>
</p>
)}
<p className="description">
{t('app.settings.cloud.cannotTurnOff', 'Nothing here turns the profile off — that is done in ServerKit Cloud, or it lifts on its own if Cloud stays unreachable past the expiry.')}
</p>
</>
)}
</div>
</div>
);
};
export default CloudTab;
@@ -11,6 +11,7 @@ import {
} from '../sidebarItems';
import { Button } from '@/components/ui/button';
import useSettingFocus from '../../hooks/useSettingFocus';
import { useManagedProfile } from '../../contexts/useManagedProfile';
import { useTranslation } from 'react-i18next';
const PRESET_ICONS = {
@@ -31,10 +32,13 @@ const SidebarSettings = () => {
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState(null);
const register = useSettingFocus();
// Items ServerKit Cloud holds under the managed profile (plan 25) are not
// offered as things to re-add — they left every preset already.
const { hiddenSidebarIds: managedHidden } = useManagedProfile();
const toggleableItems = useMemo(
() => SIDEBAR_ITEMS.filter(item => !item.alwaysVisible),
[]
() => SIDEBAR_ITEMS.filter(item => !item.alwaysVisible && !managedHidden.has(item.id)),
[managedHidden]
);
const activeHidden = preset === 'custom'
+18
View File
@@ -363,3 +363,21 @@ export function applyWorkspaceNavPermissions(items, workspace, user) {
const allowed = new Set(allowedIds);
return items.filter(item => item.alwaysVisible || allowed.has(item.id));
}
// ---------------------------------------------------------------------------
// Managed profile (ServerKit Cloud, plan 25)
//
// The one table mapping a held managed capability to the sidebar ids it
// removes, the route prefixes whose pages render the managed card, and the
// in-page controls that collapse — re-exported here because this module is
// where the mapping is read alongside the items it hides. The table itself
// lives in ./managedCapabilities.js, which stays dependency-free so the drift
// guard (routes/__tests__/managedProfile.test.mjs) can import it under plain
// node. Hidden is not removed: every route stays registered and every API
// keeps answering.
export {
MANAGED_CAPABILITY_MAP,
managedSidebarIds,
managedCapabilityForPath,
managedControlHeld,
} from './managedCapabilities';
@@ -0,0 +1,63 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import api from '../services/api';
import ManagedProfileContext from './managedProfileContextValue';
import {
MANAGED_CAPABILITY_MAP,
managedCapabilityForPath,
managedControlHeld,
managedSidebarIds,
} from '../components/sidebarItems';
// The managed profile (plan 25). One fetch per panel load, next to the
// connect status: ServerKit Cloud's signed presentation policy, naming the
// capabilities it holds for this customer. Everything the UI needs derives
// from it here so the sidebar, the palette, the route gate and the in-page
// controls all read the same answer.
//
// The endpoint is admin-only; a refusal (a viewer, an older backend, a panel
// that was never paired) reads as "no profile", which is the safe default:
// nothing is ever hidden on a guess.
export function ManagedProfileProvider({ children }) {
const [profile, setProfile] = useState(null);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
try {
const data = await api.getManagedProfile();
setProfile(data && data.profile !== 'none' ? data : null);
} catch {
setProfile(null);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const value = useMemo(() => {
const active = Boolean(profile?.active);
const capabilities = active ? (profile.capabilities || []) : [];
const hiddenSidebarIds = managedSidebarIds(capabilities);
return {
loading,
profile,
active,
// The document lapsed: the full panel is back, with a banner.
lapsed: Boolean(profile?.expired),
capabilities,
hiddenSidebarIds,
capabilityLabel: (cap) => MANAGED_CAPABILITY_MAP[cap]?.label || cap,
cardForPath: (pathname) => managedCapabilityForPath(pathname, capabilities),
isControlHeld: (control) => managedControlHeld(control, capabilities),
reload: load,
};
}, [loading, profile, load]);
return (
<ManagedProfileContext.Provider value={value}>
{children}
</ManagedProfileContext.Provider>
);
}
export default ManagedProfileProvider;
@@ -0,0 +1,19 @@
import { createContext } from 'react';
// The managed-profile context value (plan 25). Kept in its own module so the
// provider (a component file) and useManagedProfile (the hook) share one
// context object without either exporting across the react-refresh boundary.
const ManagedProfileContext = createContext({
loading: true,
profile: null,
active: false,
lapsed: false,
capabilities: [],
hiddenSidebarIds: new Set(),
capabilityLabel: () => null,
cardForPath: () => null,
isControlHeld: () => false,
reload: () => {},
});
export default ManagedProfileContext;
@@ -0,0 +1,13 @@
import { useContext } from 'react';
import ManagedProfileContext from './managedProfileContextValue';
// Read the managed profile (plan 25) anywhere under DashboardLayout: whether
// it is active, the held capabilities, the sidebar ids it removes, the
// capability a path's managed card belongs to, and whether a named in-page
// control is held. Outside the provider the context default answers "no
// profile" — nothing is ever hidden on a guess.
export function useManagedProfile() {
return useContext(ManagedProfileContext);
}
export default useManagedProfile;
+3
View File
@@ -82,6 +82,9 @@ export const SETTINGS_INDEX = [
{ id: 'connections-email-relay', labelKey: 'app.settingsIndex.emailRelay', label: 'Email relay', descriptionKey: 'app.settingsIndex.setUpAnEmailRelayService', description: 'Set up an email relay service for outgoing mail', keywords: 'email relay smtp outgoing', tab: 'connections', adminOnly: true },
{ id: 'connections-container-registry', labelKey: 'app.settingsIndex.containerRegistry', label: 'Container registry', descriptionKey: 'app.settingsIndex.connectContainerRegistriesForDockerImage', description: 'Connect container registries for Docker image pulls', keywords: 'container registry docker docker hub', tab: 'connections', adminOnly: true },
{ id: 'cloud-connection', labelKey: 'app.settingsIndex.cloudConnection', label: 'ServerKit Cloud connection', descriptionKey: 'app.settingsIndex.cloudConnectionDesc', description: 'Read this panel\'s ServerKit Cloud connection state', keywords: 'serverkit cloud connect pairing', tab: 'cloud', adminOnly: true },
{ id: 'cloud-managed-profile', labelKey: 'app.settingsIndex.cloudManagedProfile', label: 'Managed profile', descriptionKey: 'app.settingsIndex.cloudManagedProfileDesc', description: 'See which responsibilities ServerKit Cloud holds under the Managed service', keywords: 'managed profile cloud responsibilities', tab: 'cloud', adminOnly: true },
{ id: 'sso-general-settings', labelKey: 'app.settingsIndex.ssoGeneralSettings', label: 'SSO general settings', descriptionKey: 'app.settingsIndex.configureAutoProvisioningDefaultRolesAnd', description: 'Configure auto-provisioning, default roles, and allowed domains', keywords: 'sso auto provision default role', tab: 'sso', adminOnly: true },
{ id: 'sso-force-mode', labelKey: 'app.settingsIndex.ssoOnlyMode', label: 'SSO-only mode', descriptionKey: 'app.settingsIndex.requireAllUsersToAuthenticateVia', description: 'Require all users to authenticate via SSO', keywords: 'sso only mode password disabled', tab: 'sso', adminOnly: true },
{ id: 'sso-google', labelKey: 'app.settingsIndex.googleOauth', label: 'Google OAuth', descriptionKey: 'app.settingsIndex.configureGoogleAsAnSsoProvider', description: 'Configure Google as an SSO provider', keywords: 'google sso oauth', tab: 'sso', adminOnly: true },
+6 -1
View File
@@ -2,6 +2,7 @@ import { useMemo } from 'react';
import { useAuth } from '../contexts/useAuth.js';
import { SIDEBAR_ITEMS } from '../components/sidebarItems';
import { useWorkspace } from '../contexts/useWorkspace.js';
import { useManagedProfile } from '../contexts/useManagedProfile';
// Sidebar items that can never be hidden (Dashboard, Marketplace) — the palette
// mirrors the sidebar's `alwaysVisible` so a workspace nav map can't hide them.
@@ -16,6 +17,8 @@ const ALWAYS_VISIBLE = new Set(
* - Items tied to a sidebar `navId` respect the active workspace's per-role
* nav-permission map (same source as applyWorkspaceNavPermissions), which
* only ever NARROWS a member's view.
* - Items whose capability ServerKit Cloud holds under the managed profile
* (plan 25) leave the palette too, same as they leave every sidebar preset.
*
* Note: this intentionally does NOT apply personal sidebar-preset hiding — a page
* you hid from your sidebar for tidiness stays reachable via the palette.
@@ -23,6 +26,7 @@ const ALWAYS_VISIBLE = new Set(
export default function usePaletteAuthz() {
const { isAdmin, hasPermission } = useAuth();
const { activeWorkspace } = useWorkspace();
const { hiddenSidebarIds: managedHidden } = useManagedProfile();
return useMemo(() => {
const navMap = activeWorkspace?.settings?.nav || null;
@@ -30,6 +34,7 @@ export default function usePaletteAuthz() {
const allowNav = (navId) => {
if (!navId) return true;
if (managedHidden.has(navId)) return false;
if (isAdmin) return true;
if (ALWAYS_VISIBLE.has(navId)) return true;
// Per-user feature permissions: files.read=false 403s every files
@@ -47,5 +52,5 @@ export default function usePaletteAuthz() {
};
return { isAdmin, allowNav, allowItem };
}, [isAdmin, hasPermission, activeWorkspace]);
}, [isAdmin, hasPermission, activeWorkspace, managedHidden]);
}
+52
View File
@@ -4997,6 +4997,25 @@
"admin": "Admin",
"aiAssistant": "AI Assistant",
"appearance": "Appearance",
"cloud": {
"cannotTurnOff": "Nothing here turns the profile off — that is done in ServerKit Cloud, or it lifts on its own if Cloud stays unreachable past the expiry.",
"connection": "Connection",
"lapsed": "The profile below lapsed — Cloud has been unreachable past its expiry, so the full panel is back. It returns if Cloud reconnects and re-sends it.",
"managedProfile": "Managed profile",
"noProfile": "No managed profile. ServerKit Cloud has not taken over any part of this panel — everything you see is yours to run.",
"notPaired": "This panel is not connected to ServerKit Cloud.",
"openInCloud": "Open this server in ServerKit Cloud",
"organisation": "Organisation",
"override": "Lifted for a support session until {{until}}. The profile returns on its own afterwards.",
"overrideReason": "Lifted for a support session until {{until}} — {{reason}}. The profile returns on its own afterwards.",
"policyExpires": "Policy expires",
"policyId": "Policy",
"profileIntro": "{{heldBy}} took over the responsibilities below on {{since}}. Pages they cover show a managed card instead of their tools; every route and API keeps working.",
"serverName": "Server name in Cloud",
"state": "State",
"subtitle": "What ServerKit Cloud does for this server, and what it has taken over",
"title": "ServerKit Cloud"
},
"connections": "Connections",
"iconReference": "Icon Reference",
"localDev": "Local Dev",
@@ -5006,6 +5025,7 @@
"preferences": "Preferences",
"profile": "Profile",
"recycleBin": "Recycle bin",
"serverkitCloud": "ServerKit Cloud",
"settingsSection": "Settings section",
"sidebar": "Sidebar",
"siteSettings": "Site Settings",
@@ -5054,6 +5074,10 @@
"chooseHowToReceiveNotificationsEmail": "Choose how to receive notifications (email, Discord, Slack, Telegram)",
"chooseTheLanguageModelToUse": "Choose the language model to use",
"chooseWhichAlertTypesToReceive": "Choose which alert types to receive (critical, warning, info, success)",
"cloudConnection": "ServerKit Cloud connection",
"cloudConnectionDesc": "Read this panel's ServerKit Cloud connection state",
"cloudManagedProfile": "Managed profile",
"cloudManagedProfileDesc": "See which responsibilities ServerKit Cloud holds under the Managed service",
"cloudProviders": "Cloud providers",
"colorTheme": "Color theme",
"configureAGenericOpenidConnectProvider": "Configure a generic OpenID Connect provider",
@@ -7084,6 +7108,34 @@
},
"waitingFirstFrame": "Waiting for first frame…"
},
"managed": {
"capability": {
"backups_schedule": "Backup scheduling",
"config_templates": "Server templates",
"firewall": "Firewall",
"fleet": "Server fleet",
"monitoring_alerts": "Alert rules",
"provisioning": "Server provisioning",
"updates": "Updates"
},
"capabilityNote": {
"backups_schedule": "Backup schedules and destinations; local restore stays with you",
"config_templates": "Config Templates",
"firewall": "Firewall management",
"fleet": "Servers, Agent Fleet and Fleet Proxy",
"monitoring_alerts": "Alert rules and channels; the live charts stay",
"provisioning": "Cloud Servers",
"updates": "Panel and agent update screens and self-update"
},
"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.",
"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.",
"openCloud": "Open in ServerKit Cloud",
"title": "{{label}} is managed by {{heldBy}}",
"updatesNote": "The panel still updates itself; only the update screens are hidden."
},
"lapsed": "The managed profile from ServerKit Cloud lapsed — the full panel is back. It returns if Cloud reconnects and re-sends it."
},
"nav": {
"accountMenu": "Account and preferences",
"allSettings": "All Settings",
+31 -3
View File
@@ -19,6 +19,9 @@ import ErrorBoundary from '../components/ErrorBoundary';
import { useShortcut } from '../hooks/useShortcut';
import { useTranslation } from 'react-i18next';
import { OperationsProvider } from '../contexts/OperationsContext';
import { ManagedProfileProvider } from '../contexts/ManagedProfileContext';
import { useManagedProfile } from '../contexts/useManagedProfile';
import ManagedCard from '../components/ManagedCard';
import { WalkthroughProvider } from '../contexts/WalkthroughContext';
import { ShellDockProvider } from '../contexts/ShellDockContext';
import WalkthroughHub from '../components/WalkthroughHub';
@@ -117,6 +120,7 @@ const DashboardLayout = () => {
<ConfirmProvider>
<WalkthroughProvider>
<ShellDockProvider>
<ManagedProfileProvider>
<div className="dashboard-layout">
<StagingBanner />
<MobileTopBar navOpen={navOpen} onToggle={() => setNavOpen(prev => !prev)} />
@@ -134,9 +138,7 @@ const DashboardLayout = () => {
)}
<main className={`main-content${isFullPageRoute ? ' main-content--full-page' : ''}`}>
{!isFullPageRoute && <SystemNotices />}
<ErrorBoundary resetKey={location.pathname}>
<Outlet />
</ErrorBoundary>
<ManagedOutlet pathname={location.pathname} isFullPageRoute={isFullPageRoute} />
</main>
<CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} />
<OperationsDock hideLauncher statusbarMode />
@@ -145,6 +147,7 @@ const DashboardLayout = () => {
<GlobalStatusBar onOpenPalette={() => setPaletteOpen(true)} />
<PluginLoader api={api} />
</div>
</ManagedProfileProvider>
</ShellDockProvider>
</WalkthroughProvider>
</ConfirmProvider>
@@ -154,4 +157,29 @@ const DashboardLayout = () => {
);
};
// The managed-profile gate (plan 25). A held capability's route still renders —
// it renders the managed card instead of the page content, so deep links,
// handoffs and exports keep landing somewhere truthful. When the policy has
// lapsed (Cloud unreachable past the expiry window) the full panel is back and
// one banner says why.
const ManagedOutlet = ({ pathname, isFullPageRoute }) => {
const { t } = useTranslation();
const { active, profile, lapsed, cardForPath } = useManagedProfile();
const managedCapability = active && !isFullPageRoute ? cardForPath(pathname) : null;
return (
<>
{lapsed && (
<div className="managed-lapsed-banner" data-testid="managed-profile-lapsed">
{t('managed.lapsed', 'The managed profile from ServerKit Cloud lapsed — the full panel is back. It returns if Cloud reconnects and re-sends it.')}
</div>
)}
<ErrorBoundary resetKey={pathname}>
{managedCapability
? <ManagedCard capability={managedCapability} profile={profile} />
: <Outlet />}
</ErrorBoundary>
</>
);
};
export default DashboardLayout;
+16 -2
View File
@@ -24,6 +24,8 @@ import SchedulesTable from '../components/backups/SchedulesTable';
import AddScheduleModal from '../components/backups/AddScheduleModal';
import { useBackupSchedules } from '../hooks/useBackupSchedules';
import StorageDestinations from '../components/backups/StorageDestinations';
import { useManagedProfile } from '../contexts/useManagedProfile';
import ManagedCard from '../components/ManagedCard';
import { useTopbarActions, useTopbarChrome } from '@/hooks/useTopbarActions';
import { useTableSort } from '@/hooks/useTableSort';
import { useColumnVisibility } from '@/hooks/useColumnVisibility';
@@ -131,6 +133,10 @@ const Backups = () => {
const [stats, setStats] = useState(null);
const scheduleStore = useBackupSchedules();
const { schedules, refresh: reloadSchedules } = scheduleStore;
// Backup scheduling held by ServerKit Cloud (plan 25): the page and local
// restore stay, the scheduling and destination surfaces become the card.
const { isControlHeld, profile: managedProfile } = useManagedProfile();
const backupsManaged = isControlHeld('backup-scheduling');
const [config, setConfig] = useState(null);
const [storageConfig, setStorageConfig] = useState(null);
const [costSummary, setCostSummary] = useState(null);
@@ -671,7 +677,7 @@ const Backups = () => {
// that tab was asking.
useTopbarActions(() => (
<>
{activeTab === 'schedules' ? (
{activeTab === 'schedules' && !backupsManaged ? (
<Button size="sm" onClick={() => setShowScheduleModal(true)}>
<Plus size={16} />
{t('app.backups.newSchedule', 'New schedule')}
@@ -690,7 +696,7 @@ const Backups = () => {
/>
)}
</>
), [activeTab, search]);
), [activeTab, search, backupsManaged]);
// The chrome acts on the snapshot table, so it is only published while that
// section is on screen — the other tabs are forms and a KPI band, with
@@ -795,6 +801,9 @@ const Backups = () => {
)}
{activeTab === 'schedules' && (
backupsManaged ? (
<ManagedCard capability="backups_schedule" profile={managedProfile} compact />
) : (
<>
{schedules.length === 0 ? (
<EmptyState
@@ -813,9 +822,13 @@ const Backups = () => {
/>
)}
</>
)
)}
{activeTab === 'storage' && (
backupsManaged ? (
<ManagedCard capability="backups_schedule" profile={managedProfile} compact />
) : (
<>
<StorageDestinations
stats={stats}
@@ -1035,6 +1048,7 @@ const Backups = () => {
</SharedCardContent>
</SharedCard>
</>
)
)}
{activeTab === 'settings' && (
+13 -3
View File
@@ -22,20 +22,21 @@ import IconReferenceTab from '../components/settings/IconReferenceTab';
import AISettingsTab from '../components/settings/AISettingsTab';
import WebhooksTab from '../components/settings/WebhooksTab';
import AboutTab from '../components/settings/AboutTab';
import CloudTab from '../components/settings/CloudTab';
import PluginSlot from '../components/PluginSlot';
import { Activity, Code, Database, Layers, Link2, PaintBucket, Sparkles, Trash2, Webhook, Settings as SettingsIcon } from 'lucide-react';
import { Activity, CloudCog, Code, Database, Layers, Link2, PaintBucket, Sparkles, Trash2, Webhook, Settings as SettingsIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { SegControl } from '@/components/ds';
import PageLayout from '../layouts/PageLayout';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
const VALID_TABS = ['profile', 'security', 'connections', 'appearance', 'sidebar', 'whitelabel', 'notifications', 'system', 'users', 'activity', 'site', 'sso', 'api', 'webhooks', 'ai', 'migrations', 'recyclebin', 'developer', 'about'];
const VALID_TABS = ['profile', 'security', 'connections', 'cloud', 'appearance', 'sidebar', 'whitelabel', 'notifications', 'system', 'users', 'activity', 'site', 'sso', 'api', 'webhooks', 'ai', 'migrations', 'recyclebin', 'developer', 'about'];
// Tabs that belong to the server-wide "Administration" group (admin-only); the
// rest are personal "My Account" settings. Drives the two-way section switch so
// personal prefs aren't interleaved with destructive system controls.
const ADMIN_TABS = ['users', 'activity', 'site', 'connections', 'sso', 'api', 'webhooks', 'ai', 'migrations', 'recyclebin', 'system', 'developer'];
const ADMIN_TABS = ['users', 'activity', 'site', 'connections', 'cloud', 'sso', 'api', 'webhooks', 'ai', 'migrations', 'recyclebin', 'system', 'developer'];
const Settings = () => {
const { t } = useTranslation();
@@ -232,6 +233,14 @@ const Settings = () => {
<Link2 size={18} />
{t('app.settings.connections', 'Connections')}
</Button>
<Button
variant="ghost"
className={`settings-nav-item ${activeTab === 'cloud' ? 'active' : ''}`}
onClick={() => setActiveTab('cloud')}
>
<CloudCog size={18} />
{t('app.settings.serverkitCloud', 'ServerKit Cloud')}
</Button>
<Button
variant="ghost"
className={`settings-nav-item ${activeTab === 'sso' ? 'active' : ''}`}
@@ -319,6 +328,7 @@ const Settings = () => {
{activeTab === 'profile' && <ProfileTab />}
{activeTab === 'security' && <SecuritySettingsTab />}
{activeTab === 'connections' && isAdmin && <ConnectionsHub />}
{activeTab === 'cloud' && isAdmin && <CloudTab />}
{activeTab === 'appearance' && <AppearanceTab />}
{activeTab === 'sidebar' && <SidebarSettings />}
{activeTab === 'whitelabel' && <WhiteLabelTab />}
+14 -1
View File
@@ -22,6 +22,8 @@ import ServerPicker from '@/components/templates/ServerPicker';
import { useTopbarChrome } from '@/hooks/useTopbarActions';
import { applyTableSorts, useTableSort } from '@/hooks/useTableSort';
import EmptyState from '../components/EmptyState';
import { useManagedProfile } from '../contexts/useManagedProfile';
import ManagedCard from '../components/ManagedCard';
import { useTranslation } from 'react-i18next';
// Featured templates (curated list)
@@ -229,6 +231,11 @@ const Templates = () => {
const [showInstallModal, setShowInstallModal] = useState(false);
const [filtersOpen, setFiltersOpen] = useState(false);
// Deploying targets a fleet ServerKit Cloud operates under the managed
// profile (plan 25): the catalog stays browsable, the Deploy control goes.
const { isControlHeld, profile: managedProfile } = useManagedProfile();
const deployManaged = isControlHeld('templates-deploy');
// Initialize from URL params
const selectedCategory = searchParams.get('category') || '';
const selectedKind = searchParams.get('kind') || '';
@@ -391,6 +398,7 @@ const Templates = () => {
// detection wizard had nothing left to ask; it deploys from the same
// drawer as everything else.
async function handleDeploy(template) {
if (deployManaged) return;
if (template.id === 'wordpress') {
navigate('/wordpress');
return;
@@ -543,6 +551,9 @@ const Templates = () => {
{/* Templates Grid */}
<div className="templates-grid">
{deployManaged && (
<ManagedCard capability="fleet" profile={managedProfile} compact />
)}
{sortedTemplates.length === 0 ? (
<EmptyState
icon={LayoutTemplate}
@@ -558,7 +569,7 @@ const Templates = () => {
sortedTemplates.map(template => {
const isRepo = (template.kind || 'compose') === 'repo';
return (
<div key={template.id} className="tpl-card" onClick={() => handleDeploy(template)}>
<div key={template.id} className="tpl-card" onClick={() => !deployManaged && handleDeploy(template)}>
{isFeatured(template.id) && (
<span className="tpl-ft" title={t('app.templates.featured', 'Featured')}>
<Star size={14} />
@@ -593,6 +604,7 @@ const Templates = () => {
<BookOpen size={12} />
</span>
)}
{!deployManaged && (
<Button
size="sm"
className="tpl-deploy"
@@ -603,6 +615,7 @@ const Templates = () => {
>
<Rocket size={12} /> {t('app.templates.deploy', 'Deploy')}
</Button>
)}
</div>
</div>
);
@@ -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',
]);
});
+10
View File
@@ -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');
}
@@ -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;
}
+1
View File
@@ -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';