mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-25 12:37:52 +09:00
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:
1 parent
f4c641032a
commit
d866de5439
26 files changed
+1224
-16
No files matched your search
@@ -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())
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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;
|
||||
@@ -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 },
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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' && (
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in new issue
Block a user