Add registrars, GitLab, DO/Godaddy DNS and SMTP relay

Introduce a broad Connections expansion: add RegistrarConnection model, RegistrarService and /api/v1/registrars endpoints to surface domain portfolios and expiry. Add EmailRelayService and API routes plus Postfix relay wiring to configure/test/disable outbound SMTP. Extend DNSProviderService with DigitalOcean and GoDaddy implementations and helpers. Add GitLab support to source connection service and routes. Frontend: expand Connections hub, provider catalog, modal bodies, provider icons, RegistrarPortfolio component, API client methods and styles. Add docs/CONNECTIONS_ROADMAP.md describing roadmap and intent.
This commit is contained in:
Juan Denis committed 2026-06-15 09:19:56 -04:00
1 parent 2e1bdbcb3d
commit c1d4653f6c
26 files changed
+2731 -336

No files matched your search

+4
View File
@@ -209,6 +209,10 @@ def create_app(config_name=None):
from app.api.source_connections import source_connections_bp
app.register_blueprint(source_connections_bp, url_prefix='/api/v1/source-connections')
# Register blueprints - Domain registrar connections (portfolio + expiry)
from app.api.registrars import registrars_bp
app.register_blueprint(registrars_bp, url_prefix='/api/v1/registrars')
# Register blueprints - Database Migrations
from app.api.migrations import migrations_bp
app.register_blueprint(migrations_bp, url_prefix='/api/v1/migrations')
+38
View File
@@ -7,6 +7,7 @@ from ..services.dns_provider_service import DNSProviderService
from ..services.spamassassin_service import SpamAssassinService
from ..services.roundcube_service import RoundcubeService
from ..services.postfix_service import PostfixService
from ..services.email_relay_service import EmailRelayService
email_bp = Blueprint('email', __name__)
@@ -313,6 +314,43 @@ def list_dns_zones(provider_id):
return jsonify(result), 200 if result.get('success') else 400
# ── Outbound SMTP Relay (smarthost) ──
@email_bp.route('/relay', methods=['GET'])
@viewer_required
def get_relay():
"""Get the outbound SMTP relay configuration (password masked)."""
return jsonify(EmailRelayService.get_config()), 200
@email_bp.route('/relay', methods=['PUT'])
@admin_required
def save_relay():
"""Save the relay configuration and apply it to Postfix (if installed)."""
data = request.get_json() or {}
if not data.get('host'):
return jsonify({'success': False, 'error': 'A relay host is required'}), 400
result = EmailRelayService.save_config(data)
return jsonify(result), 200
@email_bp.route('/relay/test', methods=['POST'])
@admin_required
def test_relay():
"""Open a real SMTP connection to validate the relay credentials."""
data = request.get_json() or {}
result = EmailRelayService.test(data)
return jsonify(result), 200 if result.get('success') else 400
@email_bp.route('/relay', methods=['DELETE'])
@admin_required
def disable_relay():
"""Disable the outbound relay (revert Postfix to direct delivery)."""
result = EmailRelayService.disable()
return jsonify(result), 200
# ── SpamAssassin Config ──
@email_bp.route('/spam/config', methods=['GET'])
+81
View File
@@ -0,0 +1,81 @@
"""Domain-registrar connections — the portfolio + expiry surface.
Connections are admin-managed (like DNS providers). Reading the portfolio is
available to any authenticated user so the Domains page and Connections cards
can show "what we own and when it lapses".
"""
from flask import Blueprint, request, jsonify
from flask_jwt_extended import jwt_required, get_jwt_identity
from app.models.user import User
from app.services.registrar_service import RegistrarService
registrars_bp = Blueprint('registrars', __name__)
def _current_user():
return User.query.get(get_jwt_identity())
@registrars_bp.route('/connections', methods=['GET'])
@jwt_required()
def list_connections():
conns = RegistrarService.list_connections()
return jsonify({'connections': [c.to_dict() for c in conns]})
@registrars_bp.route('/connections', methods=['POST'])
@jwt_required()
def add_connection():
user = _current_user()
if not user or not user.is_admin:
return jsonify({'error': 'Admin access required'}), 403
data = request.get_json() or {}
try:
conn = RegistrarService.add_connection(data, user.id)
except ValueError as e:
return jsonify({'error': str(e)}), 400
# Validate the credentials immediately; drop the row if they don't work so
# we never persist a dead connection.
test = RegistrarService.test_connection(conn)
if not test.get('success'):
RegistrarService.delete_connection(conn.id)
return jsonify({'error': test.get('error', 'Connection test failed')}), 400
return jsonify({'connection': conn.to_dict(), 'test': test}), 201
@registrars_bp.route('/connections/<int:cid>', methods=['DELETE'])
@jwt_required()
def delete_connection(cid):
user = _current_user()
if not user or not user.is_admin:
return jsonify({'error': 'Admin access required'}), 403
if not RegistrarService.delete_connection(cid):
return jsonify({'error': 'Not found'}), 404
return jsonify({'message': 'Registrar disconnected'})
@registrars_bp.route('/connections/<int:cid>/test', methods=['POST'])
@jwt_required()
def test_connection(cid):
conn = RegistrarService.get_connection(cid)
if not conn:
return jsonify({'error': 'Not found'}), 404
result = RegistrarService.test_connection(conn)
return jsonify(result), 200 if result.get('success') else 400
@registrars_bp.route('/domains', methods=['GET'])
@jwt_required()
def list_domains():
"""Aggregated domain portfolio across all connected registrars."""
return jsonify({'domains': RegistrarService.list_all_domains()})
@registrars_bp.route('/sync', methods=['POST'])
@jwt_required()
def sync():
"""Force-refresh the portfolio (and stamp last_synced_at)."""
return jsonify({'domains': RegistrarService.sync_now()})
+132
View File
@@ -141,3 +141,135 @@ def _github_error(exc):
return data.get('message') or str(exc)
except Exception:
return response.text or str(exc)
@source_connections_bp.route('/gitlab/status', methods=['GET'])
@jwt_required()
def gitlab_status():
user_id = int(get_jwt_identity())
return jsonify(SourceConnectionService.get_gitlab_status(user_id)), 200
@source_connections_bp.route('/gitlab/authorize', methods=['GET'])
@jwt_required()
def gitlab_authorize():
redirect_uri = request.args.get('redirect_uri', '')
if not redirect_uri:
return jsonify({'error': 'redirect_uri is required'}), 400
try:
auth_url, state = SourceConnectionService.generate_gitlab_authorize_url(redirect_uri)
return jsonify({'auth_url': auth_url, 'state': state}), 200
except ValueError as exc:
return jsonify({'error': str(exc)}), 400
@source_connections_bp.route('/gitlab/callback', methods=['POST'])
@jwt_required()
def gitlab_callback():
user_id = int(get_jwt_identity())
data = request.get_json() or {}
code = data.get('code', '')
state = data.get('state', '')
redirect_uri = data.get('redirect_uri', '')
if not code or not state or not redirect_uri:
return jsonify({'error': 'code, state, and redirect_uri are required'}), 400
try:
connection = SourceConnectionService.complete_gitlab_callback(
user_id=user_id,
code=code,
state=state,
redirect_uri=redirect_uri,
)
return jsonify({'connection': connection.to_dict()}), 200
except ValueError as exc:
return jsonify({'error': str(exc)}), 400
except HTTPError as exc:
return jsonify({'error': _gitlab_error(exc)}), 400
@source_connections_bp.route('/gitlab/repos', methods=['GET'])
@jwt_required()
def gitlab_repositories():
user_id = int(get_jwt_identity())
search = request.args.get('search', '')
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 50, type=int)
try:
repos = SourceConnectionService.list_gitlab_repositories(user_id, search, page, per_page)
return jsonify({'repos': repos}), 200
except ValueError as exc:
return jsonify({'error': str(exc)}), 400
except HTTPError as exc:
return jsonify({'error': _gitlab_error(exc)}), 400
@source_connections_bp.route('/gitlab/repos/<path:full_name>/branches', methods=['GET'])
@jwt_required()
def gitlab_branches(full_name):
user_id = int(get_jwt_identity())
try:
branches = SourceConnectionService.list_gitlab_branches(user_id, full_name)
return jsonify({'branches': branches}), 200
except ValueError as exc:
return jsonify({'error': str(exc)}), 400
except HTTPError as exc:
return jsonify({'error': _gitlab_error(exc)}), 400
@source_connections_bp.route('/gitlab/repos/<path:full_name>/manifest', methods=['GET'])
@jwt_required()
def gitlab_repository_manifest(full_name):
user_id = int(get_jwt_identity())
ref = request.args.get('ref') or None
try:
manifest = SourceConnectionService.get_gitlab_repository_manifest(user_id, full_name, ref)
return jsonify({'manifest': manifest}), 200
except ValueError as exc:
return jsonify({'error': str(exc)}), 400
except HTTPError as exc:
return jsonify({'error': _gitlab_error(exc)}), 400
@source_connections_bp.route('/gitlab', methods=['DELETE'])
@jwt_required()
def disconnect_gitlab():
user_id = int(get_jwt_identity())
try:
SourceConnectionService.disconnect(user_id, 'gitlab')
return jsonify({'message': 'GitLab disconnected'}), 200
except ValueError as exc:
return jsonify({'error': str(exc)}), 404
@source_connections_bp.route('/admin/gitlab', methods=['GET'])
@admin_required
def gitlab_admin_config():
return jsonify({'config': SourceConnectionService.get_gitlab_config(redacted=True)}), 200
@source_connections_bp.route('/admin/gitlab', methods=['PUT'])
@admin_required
def update_gitlab_admin_config():
user = User.query.get(get_jwt_identity())
result = SourceConnectionService.update_gitlab_config(
request.get_json() or {},
user_id=user.id if user else None,
)
return jsonify(result), 200
def _gitlab_error(exc):
response = getattr(exc, 'response', None)
if response is None:
return str(exc)
try:
data = response.json()
return data.get('message') or data.get('error_description') or data.get('error') or str(exc)
except Exception:
return response.text or str(exc)
+4 -3
View File
@@ -16,9 +16,10 @@ from app.models.wordpress_site import WordPressSite, DatabaseSnapshot, SyncJob,
from app.models.environment_activity import EnvironmentActivity
from app.models.promotion_job import PromotionJob
from app.models.sanitization_profile import SanitizationProfile
from app.models.email import EmailDomain, EmailAccount, EmailAlias, EmailForwardingRule, DNSProviderConfig
from app.models.email import EmailDomain, EmailAccount, EmailAlias, EmailForwardingRule, DNSProviderConfig, EmailRelayConfig
from app.models.oauth_identity import OAuthIdentity
from app.models.source_connection import SourceConnection
from app.models.registrar_connection import RegistrarConnection
from app.models.api_key import ApiKey
from app.models.api_usage import ApiUsageLog, ApiUsageSummary
from app.models.event_subscription import EventSubscription, EventDelivery
@@ -42,8 +43,8 @@ __all__ = [
'Server', 'ServerGroup', 'ServerMetrics', 'ServerCommand', 'AgentSession', 'AgentVersion', 'AgentRollout', 'SecurityAlert',
'WordPressSite', 'DatabaseSnapshot', 'SyncJob', 'WordPressVulnerability',
'EnvironmentActivity', 'PromotionJob', 'SanitizationProfile',
'EmailDomain', 'EmailAccount', 'EmailAlias', 'EmailForwardingRule', 'DNSProviderConfig',
'OAuthIdentity', 'SourceConnection', 'ApiKey', 'ApiUsageLog', 'ApiUsageSummary',
'EmailDomain', 'EmailAccount', 'EmailAlias', 'EmailForwardingRule', 'DNSProviderConfig', 'EmailRelayConfig',
'OAuthIdentity', 'SourceConnection', 'RegistrarConnection', 'ApiKey', 'ApiUsageLog', 'ApiUsageSummary',
'EventSubscription', 'EventDelivery', 'Invitation',
'ServerAlertThreshold', 'MetricAlert',
'AgentPlugin', 'AgentPluginInstall',
+32
View File
@@ -169,3 +169,35 @@ class DNSProviderConfig(db.Model):
def __repr__(self):
return f'<DNSProviderConfig {self.name} ({self.provider})>'
class EmailRelayConfig(db.Model):
"""Outbound SMTP relay (smarthost) for the mail server — routes outgoing mail
through a third-party provider (Postmark, SES, Mailgun, …). Single-row config;
the password is Fernet-encrypted (see app.utils.crypto)."""
__tablename__ = 'email_relay_config'
id = db.Column(db.Integer, primary_key=True)
enabled = db.Column(db.Boolean, default=False)
host = db.Column(db.String(255))
port = db.Column(db.Integer, default=587)
username = db.Column(db.String(255))
password_encrypted = db.Column(db.Text)
use_tls = db.Column(db.Boolean, default=True)
provider_hint = db.Column(db.String(40)) # 'postmark' | 'ses' | 'mailgun' | 'sendgrid' | 'custom'
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def to_dict(self):
return {
'enabled': bool(self.enabled),
'host': self.host or '',
'port': self.port or 587,
'username': self.username or '',
'use_tls': self.use_tls if self.use_tls is not None else True,
'provider_hint': self.provider_hint,
'password_set': bool(self.password_encrypted),
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
}
def __repr__(self):
return f'<EmailRelayConfig {self.host or "(unset)"}>'
@@ -0,0 +1,37 @@
from datetime import datetime
from app import db
class RegistrarConnection(db.Model):
"""A connected domain-registrar account (GoDaddy, …) used to read the domain
portfolio: registration status, expiry dates, auto-renew and nameservers.
This is an *ownership* connection, distinct from a DNS-provider connection
(DNSProviderConfig) which manages records. Credentials are stored
Fernet-encrypted via app.utils.crypto, like SourceConnection tokens.
"""
__tablename__ = 'registrar_connections'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
provider = db.Column(db.String(40), nullable=False) # 'godaddy'
name = db.Column(db.String(120), nullable=True) # user-facing label
api_key_encrypted = db.Column(db.Text, nullable=True)
api_secret_encrypted = db.Column(db.Text, nullable=True)
account_label = db.Column(db.String(180), nullable=True) # e.g. domain count / shopper id
last_synced_at = db.Column(db.DateTime, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def to_dict(self):
return {
'id': self.id,
'provider': self.provider,
'name': self.name or self.provider,
'account_label': self.account_label,
'last_synced_at': self.last_synced_at.isoformat() if self.last_synced_at else None,
'created_at': self.created_at.isoformat() if self.created_at else None,
}
def __repr__(self):
return f'<RegistrarConnection {self.provider}:{self.id}>'
+6 -1
View File
@@ -4,7 +4,12 @@ from app import db
class SourceConnection(db.Model):
"""External source-code provider connection for repository imports."""
"""External source-code provider connection for repository imports.
The ``provider`` column is a plain string and accepts any supported
provider value (e.g. ``'github'`` or ``'gitlab'``); there is no enum or
allow-list to extend.
"""
__tablename__ = 'source_connections'
+236 -3
View File
@@ -1,4 +1,4 @@
"""DNS Provider service for managing DKIM/SPF/DMARC records via Cloudflare and Route53."""
"""DNS Provider service for managing DKIM/SPF/DMARC records via Cloudflare, Route53, DigitalOcean and GoDaddy."""
import logging
from typing import Dict, List, Optional
@@ -29,8 +29,8 @@ class DNSProviderService:
api_secret: str = None, api_email: str = None,
is_default: bool = False) -> Dict:
"""Add a new DNS provider configuration."""
if provider not in ('cloudflare', 'route53'):
return {'success': False, 'error': 'Provider must be cloudflare or route53'}
if provider not in ('cloudflare', 'route53', 'digitalocean', 'godaddy'):
return {'success': False, 'error': 'Provider must be cloudflare, route53, digitalocean or godaddy'}
try:
if is_default:
# Unset other defaults
@@ -76,6 +76,10 @@ class DNSProviderService:
return cls._test_cloudflare(config)
elif config.provider == 'route53':
return cls._test_route53(config)
elif config.provider == 'digitalocean':
return cls._test_digitalocean(config)
elif config.provider == 'godaddy':
return cls._test_godaddy(config)
return {'success': False, 'error': 'Unknown provider'}
@classmethod
@@ -89,6 +93,10 @@ class DNSProviderService:
return cls._cloudflare_list_zones(config)
elif config.provider == 'route53':
return cls._route53_list_zones(config)
elif config.provider == 'digitalocean':
return cls._digitalocean_list_zones(config)
elif config.provider == 'godaddy':
return cls._godaddy_list_zones(config)
return {'success': False, 'error': 'Unknown provider'}
@classmethod
@@ -103,6 +111,10 @@ class DNSProviderService:
return cls._cloudflare_set_record(config, zone_id, record_type, name, value, ttl)
elif config.provider == 'route53':
return cls._route53_set_record(config, zone_id, record_type, name, value, ttl)
elif config.provider == 'digitalocean':
return cls._digitalocean_set_record(config, zone_id, record_type, name, value, ttl)
elif config.provider == 'godaddy':
return cls._godaddy_set_record(config, zone_id, record_type, name, value, ttl)
return {'success': False, 'error': 'Unknown provider'}
@classmethod
@@ -116,6 +128,10 @@ class DNSProviderService:
return cls._cloudflare_delete_record(config, zone_id, record_type, name)
elif config.provider == 'route53':
return cls._route53_delete_record(config, zone_id, record_type, name)
elif config.provider == 'digitalocean':
return cls._digitalocean_delete_record(config, zone_id, record_type, name)
elif config.provider == 'godaddy':
return cls._godaddy_delete_record(config, zone_id, record_type, name)
return {'success': False, 'error': 'Unknown provider'}
@classmethod
@@ -307,6 +323,23 @@ class DNSProviderService:
except Exception as e:
return {'success': False, 'error': str(e)}
@staticmethod
def _host_relative_to_zone(name: str, zone: str) -> str:
"""Compute a record host relative to its zone (``@`` for the apex).
``name`` is a FQDN (e.g. ``mail.example.com``), ``zone`` the managing
zone (e.g. ``example.com``); returns ``mail`` here, or ``@`` when the
name *is* the apex. Used by DigitalOcean/GoDaddy which address records
by zone + host rather than by FQDN.
"""
name = (name or '').strip().lower().rstrip('.')
zone = (zone or '').strip().lower().rstrip('.')
if not zone or name == zone:
return '@'
if name.endswith('.' + zone):
return name[: -len(zone) - 1]
return name or '@'
# ── Route53 Implementation ──
@classmethod
@@ -419,3 +452,203 @@ class DNSProviderService:
return {'success': True, 'message': f'{record_type} record deleted for {name}'}
except Exception as e:
return {'success': False, 'error': str(e)}
# ── DigitalOcean Implementation ──
@classmethod
def _digitalocean_headers(cls, config: DNSProviderConfig) -> Dict:
"""Build DigitalOcean API headers (single-token auth)."""
return {
'Authorization': f'Bearer {config.api_key}',
'Content-Type': 'application/json',
}
@classmethod
def _test_digitalocean(cls, config: DNSProviderConfig) -> Dict:
"""Test DigitalOcean API connection."""
try:
resp = requests.get(
'https://api.digitalocean.com/v2/domains?per_page=1',
headers=cls._digitalocean_headers(config),
timeout=15,
)
if resp.status_code == 200:
return {'success': True, 'message': 'DigitalOcean connection successful'}
data = resp.json()
return {'success': False, 'error': data.get('message', f'HTTP {resp.status_code}')}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def _digitalocean_list_zones(cls, config: DNSProviderConfig) -> Dict:
"""List DigitalOcean domains as zones (zone id == domain name)."""
try:
resp = requests.get(
'https://api.digitalocean.com/v2/domains?per_page=200',
headers=cls._digitalocean_headers(config),
timeout=15,
)
data = resp.json()
if resp.status_code != 200:
return {'success': False, 'error': data.get('message', 'Failed to list zones')}
zones = [{'id': d['name'], 'name': d['name'], 'status': 'active'}
for d in data.get('domains', [])]
return {'success': True, 'zones': zones}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def _digitalocean_set_record(cls, config: DNSProviderConfig, zone_id: str,
record_type: str, name: str, value: str, ttl: int) -> Dict:
"""Create or update a DigitalOcean DNS record (zone_id is the domain)."""
try:
headers = cls._digitalocean_headers(config)
base = f'https://api.digitalocean.com/v2/domains/{zone_id}/records'
host = cls._host_relative_to_zone(name, zone_id)
payload = {'type': record_type, 'name': host, 'data': value, 'ttl': ttl}
if record_type == 'MX':
# Input is "<priority> <target>"; split into priority + data.
parts = value.split(None, 1)
if len(parts) == 2 and parts[0].isdigit():
payload['priority'] = int(parts[0])
payload['data'] = parts[1]
# Find an existing record of the same type/host to update.
resp = requests.get(
f'{base}?type={record_type}&per_page=200',
headers=headers, timeout=15,
)
data = resp.json()
existing = [r for r in data.get('domain_records', []) if r.get('name') == host]
if existing:
record_id = existing[0]['id']
resp = requests.put(
f'{base}/{record_id}',
headers=headers, json=payload, timeout=15,
)
else:
resp = requests.post(base, headers=headers, json=payload, timeout=15)
if resp.status_code in (200, 201):
return {'success': True, 'message': f'{record_type} record set for {name}'}
data = resp.json()
return {'success': False, 'error': data.get('message', f'HTTP {resp.status_code}')}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def _digitalocean_delete_record(cls, config: DNSProviderConfig, zone_id: str,
record_type: str, name: str) -> Dict:
"""Delete a DigitalOcean DNS record (zone_id is the domain)."""
try:
headers = cls._digitalocean_headers(config)
base = f'https://api.digitalocean.com/v2/domains/{zone_id}/records'
host = cls._host_relative_to_zone(name, zone_id)
resp = requests.get(
f'{base}?type={record_type}&per_page=200',
headers=headers, timeout=15,
)
data = resp.json()
existing = [r for r in data.get('domain_records', []) if r.get('name') == host]
if not existing:
return {'success': True, 'message': 'Record not found (already deleted)'}
for record in existing:
requests.delete(f'{base}/{record["id"]}', headers=headers, timeout=15)
return {'success': True, 'message': f'{record_type} record deleted for {name}'}
except Exception as e:
return {'success': False, 'error': str(e)}
# ── GoDaddy Implementation ──
@classmethod
def _godaddy_headers(cls, config: DNSProviderConfig) -> Dict:
"""Build GoDaddy API headers (key+secret auth)."""
return {
'Authorization': f'sso-key {config.api_key}:{config.api_secret}',
'Content-Type': 'application/json',
}
@classmethod
def _test_godaddy(cls, config: DNSProviderConfig) -> Dict:
"""Test GoDaddy API connection."""
try:
resp = requests.get(
'https://api.godaddy.com/v1/domains?limit=1',
headers=cls._godaddy_headers(config),
timeout=15,
)
if resp.status_code == 200:
return {'success': True, 'message': 'GoDaddy connection successful'}
data = resp.json()
return {'success': False, 'error': data.get('message', f'HTTP {resp.status_code}')}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def _godaddy_list_zones(cls, config: DNSProviderConfig) -> Dict:
"""List GoDaddy domains as zones (zone id == domain name)."""
try:
resp = requests.get(
'https://api.godaddy.com/v1/domains',
headers=cls._godaddy_headers(config),
timeout=15,
)
if resp.status_code != 200:
data = resp.json()
return {'success': False, 'error': data.get('message', 'Failed to list zones')}
zones = [{'id': d['domain'], 'name': d['domain'],
'status': d.get('status', 'active')}
for d in resp.json()]
return {'success': True, 'zones': zones}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def _godaddy_set_record(cls, config: DNSProviderConfig, zone_id: str,
record_type: str, name: str, value: str, ttl: int) -> Dict:
"""Create or update a GoDaddy DNS record (record-typed PUT, zone_id is the domain)."""
try:
headers = cls._godaddy_headers(config)
host = cls._host_relative_to_zone(name, zone_id)
url = f'https://api.godaddy.com/v1/domains/{zone_id}/records/{record_type}/{host}'
record = {'data': value, 'ttl': ttl}
if record_type == 'MX':
# Input is "<priority> <target>"; GoDaddy wants priority + data.
parts = value.split(None, 1)
if len(parts) == 2 and parts[0].isdigit():
record['priority'] = int(parts[0])
record['data'] = parts[1]
resp = requests.put(url, headers=headers, json=[record], timeout=15)
if resp.status_code in (200, 201):
return {'success': True, 'message': f'{record_type} record set for {name}'}
data = resp.json()
return {'success': False, 'error': data.get('message', f'HTTP {resp.status_code}')}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def _godaddy_delete_record(cls, config: DNSProviderConfig, zone_id: str,
record_type: str, name: str) -> Dict:
"""Delete a GoDaddy DNS record (record-typed DELETE, zone_id is the domain)."""
try:
headers = cls._godaddy_headers(config)
host = cls._host_relative_to_zone(name, zone_id)
url = f'https://api.godaddy.com/v1/domains/{zone_id}/records/{record_type}/{host}'
resp = requests.delete(url, headers=headers, timeout=15)
if resp.status_code in (200, 204):
return {'success': True, 'message': f'{record_type} record deleted for {name}'}
if resp.status_code == 404:
return {'success': True, 'message': 'Record not found (already deleted)'}
data = resp.json()
return {'success': False, 'error': data.get('message', f'HTTP {resp.status_code}')}
except Exception as e:
return {'success': False, 'error': str(e)}
+123
View File
@@ -0,0 +1,123 @@
"""Outbound SMTP relay (smarthost) for the mail server.
Persists a single relay configuration (host/port/credentials, password
Fernet-encrypted) and applies it to Postfix when present. The config is stored
platform-agnostically so the dev panel can hold it; the Postfix apply is a no-op
(with a note) on hosts where Postfix isn't installed. `test()` opens a real SMTP
connection so credentials are validated on any OS.
"""
import ssl
import smtplib
import logging
from app import db
from app.models.email import EmailRelayConfig
from app.services.postfix_service import PostfixService
from app.utils.crypto import encrypt_secret, decrypt_secret
logger = logging.getLogger(__name__)
def _is_masked(value):
"""True for our masking sentinels, so a round-tripped masked value never
overwrites the stored secret."""
if not value:
return False
return value.startswith('****') or set(value) == {'*'}
class EmailRelayService:
DEFAULT = {
'enabled': False, 'host': '', 'port': 587, 'username': '',
'use_tls': True, 'provider_hint': None, 'password_set': False,
}
@staticmethod
def _row():
return EmailRelayConfig.query.first()
@classmethod
def get_config(cls):
row = cls._row()
return row.to_dict() if row else dict(cls.DEFAULT)
@classmethod
def save_config(cls, data):
row = cls._row() or EmailRelayConfig()
row.host = (data.get('host') or '').strip()
try:
row.port = int(data.get('port') or 587)
except (TypeError, ValueError):
row.port = 587
row.username = (data.get('username') or '').strip()
row.use_tls = bool(data.get('use_tls', True))
row.enabled = bool(data.get('enabled', True))
row.provider_hint = data.get('provider_hint')
pw = data.get('password')
if pw and not _is_masked(pw):
row.password_encrypted = encrypt_secret(pw)
if row.id is None:
db.session.add(row)
db.session.commit()
return {'config': row.to_dict(), 'apply': cls.apply(row)}
@classmethod
def apply(cls, row=None):
"""Push the stored config to Postfix (if installed)."""
row = row or cls._row()
if not row:
return {'applied': False, 'note': 'No relay configured'}
status = PostfixService.get_status()
if not status.get('installed'):
return {'applied': False, 'note': 'Saved. Postfix is not installed here — the relay applies on a configured mail server.'}
if row.enabled and row.host:
password = decrypt_secret(row.password_encrypted) if row.password_encrypted else ''
result = PostfixService.configure_relay(row.host, row.port, row.username, password, row.use_tls)
else:
result = PostfixService.disable_relay()
result['applied'] = result.get('success', False)
return result
@classmethod
def disable(cls):
row = cls._row()
if row:
row.enabled = False
db.session.commit()
return cls.apply(row)
@classmethod
def test(cls, data):
"""Open a real SMTP connection to validate host/port/credentials."""
row = cls._row()
host = (data.get('host') or (row.host if row else '') or '').strip()
if not host:
return {'success': False, 'error': 'A relay host is required'}
try:
port = int(data.get('port') or (row.port if row else 587))
except (TypeError, ValueError):
port = 587
username = data.get('username') or (row.username if row else '')
use_tls = data.get('use_tls', row.use_tls if row else True)
password = data.get('password')
if password and _is_masked(password):
password = None
if not password and row and row.password_encrypted:
password = decrypt_secret(row.password_encrypted)
try:
if port == 465:
server = smtplib.SMTP_SSL(host, port, timeout=15)
else:
server = smtplib.SMTP(host, port, timeout=15)
server.ehlo()
if use_tls:
server.starttls(context=ssl.create_default_context())
server.ehlo()
if username:
server.login(username, password or '')
server.quit()
return {'success': True, 'message': f'Connected to {host}:{port}'}
except Exception as e:
return {'success': False, 'error': str(e)}
+39
View File
@@ -388,3 +388,42 @@ mailbox_size_limit = 0
return {'success': False, 'error': result.stderr or 'Restart failed'}
except Exception as e:
return {'success': False, 'error': str(e)}
POSTFIX_SASL_PASSWD = '/etc/postfix/sasl_passwd'
@classmethod
def configure_relay(cls, host: str, port: int = 587, username: str = None,
password: str = None, use_tls: bool = True) -> Dict:
"""Route outbound mail through an external SMTP smarthost (relayhost).
Sets relayhost + SASL auth in main.cf and writes the credential map. Linux
/ Postfix only — the caller persists the config regardless of platform.
"""
try:
relay = f'[{host}]:{port}'
run_privileged(['postconf', '-e', f'relayhost={relay}'])
if username:
run_privileged(['postconf', '-e', 'smtp_sasl_auth_enable=yes'])
run_privileged(['postconf', '-e', f'smtp_sasl_password_maps=hash:{cls.POSTFIX_SASL_PASSWD}'])
run_privileged(['postconf', '-e', 'smtp_sasl_security_options=noanonymous'])
run_privileged(['tee', cls.POSTFIX_SASL_PASSWD], input=f'{relay} {username}:{password or ""}\n')
run_privileged(['chmod', '600', cls.POSTFIX_SASL_PASSWD])
run_privileged(['postmap', cls.POSTFIX_SASL_PASSWD])
run_privileged(['postconf', '-e', f'smtp_tls_security_level={"encrypt" if use_tls else "may"}'])
run_privileged(['postfix', 'reload'])
return {'success': True, 'message': f'Relaying outbound mail through {relay}'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def disable_relay(cls) -> Dict:
"""Stop relaying through a smarthost (revert to direct delivery)."""
try:
run_privileged(['postconf', '-e', 'relayhost='])
run_privileged(['postconf', '-e', 'smtp_sasl_auth_enable=no'])
run_privileged(['postconf', '-e', 'smtp_sasl_password_maps='])
run_privileged(['rm', '-f', cls.POSTFIX_SASL_PASSWD, cls.POSTFIX_SASL_PASSWD + '.db'], check=False)
run_privileged(['postfix', 'reload'])
return {'success': True, 'message': 'Outbound relay disabled'}
except Exception as e:
return {'success': False, 'error': str(e)}
+186
View File
@@ -0,0 +1,186 @@
"""Domain-registrar portfolio service.
Connects to registrar APIs (GoDaddy today; Namecheap is a planned addition) to
answer the question "what domains do I own and when do they expire?" — the data
that powers the Connections → Registrars cards and the Domains-page portfolio.
Credentials live on RegistrarConnection rows, Fernet-encrypted. The public
methods return plain dicts so the API layer can jsonify them directly.
"""
import logging
from datetime import datetime, timezone
import requests
from app import db
from app.models.registrar_connection import RegistrarConnection
from app.utils.crypto import encrypt_secret, decrypt_secret
logger = logging.getLogger(__name__)
class RegistrarService:
# Only providers we can fully read are listed here; the connect form is
# gated on this. Namecheap (XML API + IP allow-listing) is intentionally
# left out until implemented, and shown as "coming soon" in the catalog.
SUPPORTED = {
'godaddy': {'name': 'GoDaddy', 'fields': ['api_key', 'api_secret']},
}
GODADDY_BASE = 'https://api.godaddy.com/v1'
# --- Connections (CRUD) ---
@staticmethod
def list_connections():
return RegistrarConnection.query.order_by(RegistrarConnection.created_at.desc()).all()
@staticmethod
def get_connection(cid):
return RegistrarConnection.query.get(cid)
@classmethod
def add_connection(cls, data, user_id=None):
provider = (data.get('provider') or '').lower().strip()
if provider not in cls.SUPPORTED:
raise ValueError(f'Unsupported registrar: {provider or "(none)"}')
api_key = (data.get('api_key') or '').strip()
api_secret = (data.get('api_secret') or '').strip()
if not api_key or not api_secret:
raise ValueError('api_key and api_secret are required')
conn = RegistrarConnection(
provider=provider,
name=(data.get('name') or '').strip() or cls.SUPPORTED[provider]['name'],
api_key_encrypted=encrypt_secret(api_key),
api_secret_encrypted=encrypt_secret(api_secret),
user_id=user_id,
)
db.session.add(conn)
db.session.commit()
return conn
@staticmethod
def delete_connection(cid):
conn = RegistrarConnection.query.get(cid)
if not conn:
return False
db.session.delete(conn)
db.session.commit()
return True
@staticmethod
def _creds(conn):
return decrypt_secret(conn.api_key_encrypted), decrypt_secret(conn.api_secret_encrypted)
# --- GoDaddy ---
@classmethod
def _godaddy_headers(cls, conn):
key, secret = cls._creds(conn)
return {'Authorization': f'sso-key {key}:{secret}', 'Accept': 'application/json'}
@classmethod
def _godaddy_list_domains(cls, conn):
resp = requests.get(
f'{cls.GODADDY_BASE}/domains',
params={'limit': 1000, 'statuses': 'ACTIVE'},
headers=cls._godaddy_headers(conn),
timeout=20,
)
resp.raise_for_status()
rows = resp.json() if isinstance(resp.json(), list) else []
return [
cls._normalize_domain(conn, {
'domain': d.get('domain'),
'status': d.get('status'),
'expires': d.get('expires'),
'auto_renew': d.get('renewAuto'),
'locked': d.get('locked'),
'nameservers': d.get('nameServers'),
'created': d.get('createdAt'),
})
for d in rows
]
# --- Public capability methods ---
@classmethod
def test_connection(cls, conn):
try:
if conn.provider == 'godaddy':
resp = requests.get(
f'{cls.GODADDY_BASE}/domains', params={'limit': 1},
headers=cls._godaddy_headers(conn), timeout=15,
)
if resp.status_code == 200:
return {'success': True, 'message': 'GoDaddy connection works'}
if resp.status_code in (401, 403):
return {'success': False, 'error': 'Access denied — check the API key/secret (must be a Production key, not OTE).'}
return {'success': False, 'error': f'GoDaddy returned HTTP {resp.status_code}'}
return {'success': False, 'error': f'Unsupported registrar: {conn.provider}'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def list_domains(cls, conn):
if conn.provider == 'godaddy':
return cls._godaddy_list_domains(conn)
return []
@classmethod
def list_all_domains(cls):
"""Aggregate every domain across all connected registrars, soonest-to-expire first."""
domains = []
for conn in cls.list_connections():
try:
domains.extend(cls.list_domains(conn))
except Exception as e:
logger.warning(f'Registrar {conn.id} ({conn.provider}) domain list failed: {e}')
domains.sort(key=lambda d: (d.get('days_until_expiry') is None, d.get('days_until_expiry') if d.get('days_until_expiry') is not None else 0))
return domains
@classmethod
def sync_now(cls):
"""Refresh the portfolio and stamp last_synced_at on every connection."""
domains = cls.list_all_domains()
now = datetime.utcnow()
conns = cls.list_connections()
for conn in conns:
conn.last_synced_at = now
# Surface the per-account domain count as the card subtitle.
count = sum(1 for d in domains if d.get('connection_id') == conn.id)
conn.account_label = f'{count} domain' + ('' if count == 1 else 's')
db.session.commit()
return domains
# --- Helpers ---
@staticmethod
def _normalize_domain(conn, d):
expires_raw = d.get('expires')
expires_at = None
days = None
if expires_raw:
try:
dt = datetime.fromisoformat(str(expires_raw).replace('Z', '+00:00'))
expires_at = dt
if dt.tzinfo:
days = (dt - datetime.now(timezone.utc)).days
else:
days = (dt - datetime.utcnow()).days
except Exception:
pass
return {
'domain': d.get('domain'),
'registrar': conn.provider,
'registrar_name': conn.name or conn.provider,
'connection_id': conn.id,
'status': d.get('status'),
'expires_at': expires_at.isoformat() if expires_at else None,
'days_until_expiry': days,
'auto_renew': d.get('auto_renew'),
'locked': d.get('locked'),
'nameservers': d.get('nameservers') or [],
}
@@ -1,4 +1,4 @@
"""Source provider connection service for GitHub repository imports."""
"""Source provider connection service for GitHub and GitLab repository imports."""
import base64
import os
@@ -24,6 +24,11 @@ class SourceConnectionService:
GITHUB_API_URL = 'https://api.github.com'
GITHUB_SCOPES = ['repo', 'read:user', 'user:email']
GITLAB_AUTHORIZE_URL = 'https://gitlab.com/oauth/authorize'
GITLAB_TOKEN_URL = 'https://gitlab.com/oauth/token'
GITLAB_API_URL = 'https://gitlab.com/api/v4'
GITLAB_SCOPES = ['read_user', 'read_api', 'read_repository']
@classmethod
def get_github_config(cls, redacted=False):
client_id = (
@@ -213,17 +218,193 @@ class SourceConnectionService:
connection = SourceConnection.query.filter_by(
id=connection_id,
user_id=user_id,
provider='github',
).first()
if not connection:
raise ValueError('GitHub connection not found')
if not connection or connection.provider not in ('github', 'gitlab'):
raise ValueError('Source connection not found')
token = cls._decrypt_connection_token(connection)
owner, repo = cls._split_full_name(full_name)
public_url = f'https://github.com/{owner}/{repo}.git'
auth_url = f'https://x-access-token:{quote(token, safe="")}@github.com/{owner}/{repo}.git'
if connection.provider == 'gitlab':
repo_path = cls._gitlab_repo_path(full_name)
public_url = f'https://gitlab.com/{repo_path}.git'
auth_url = f'https://oauth2:{quote(token, safe="")}@gitlab.com/{repo_path}.git'
else:
owner, repo = cls._split_full_name(full_name)
public_url = f'https://github.com/{owner}/{repo}.git'
auth_url = f'https://x-access-token:{quote(token, safe="")}@github.com/{owner}/{repo}.git'
return {'clone_url': auth_url, 'public_url': public_url}
# ------------------------------------------------------------------
# GitLab
# ------------------------------------------------------------------
@classmethod
def get_gitlab_config(cls, redacted=False):
client_id = (
SettingsService.get('source_gitlab_client_id', '')
or os.environ.get('SOURCE_GITLAB_CLIENT_ID', '')
or os.environ.get('SERVERKIT_GITLAB_CLIENT_ID', '')
)
client_secret = (
SettingsService.get('source_gitlab_client_secret', '')
or os.environ.get('SOURCE_GITLAB_CLIENT_SECRET', '')
or os.environ.get('SERVERKIT_GITLAB_CLIENT_SECRET', '')
)
if redacted and client_secret:
client_secret = f'****{client_secret[-4:]}'
return {
'client_id': client_id,
'client_secret': client_secret,
'configured': bool(client_id and client_secret),
'scopes': cls.GITLAB_SCOPES,
}
@classmethod
def update_gitlab_config(cls, data, user_id=None):
updated = []
if 'client_id' in data:
SettingsService.set('source_gitlab_client_id', data.get('client_id') or '', user_id=user_id)
updated.append('client_id')
if 'client_secret' in data:
value = data.get('client_secret') or ''
if not (isinstance(value, str) and value.startswith('****')):
SettingsService.set('source_gitlab_client_secret', value, user_id=user_id)
updated.append('client_secret')
return {'updated': updated, 'config': cls.get_gitlab_config(redacted=True)}
@classmethod
def generate_gitlab_authorize_url(cls, redirect_uri):
config = cls.get_gitlab_config()
if not config['configured']:
raise ValueError('GitLab source connection is not configured')
state = secrets.token_urlsafe(32)
session['source_gitlab_state'] = state
params = {
'client_id': config['client_id'],
'redirect_uri': redirect_uri,
'response_type': 'code',
'scope': ' '.join(cls.GITLAB_SCOPES),
'state': state,
}
return f'{cls.GITLAB_AUTHORIZE_URL}?{urlencode(params)}', state
@classmethod
def complete_gitlab_callback(cls, user_id, code, state, redirect_uri):
expected_state = session.pop('source_gitlab_state', None)
if not expected_state or state != expected_state:
raise ValueError('Invalid GitLab OAuth state')
config = cls.get_gitlab_config()
if not config['configured']:
raise ValueError('GitLab source connection is not configured')
token_response = requests.post(
cls.GITLAB_TOKEN_URL,
headers={'Accept': 'application/json'},
data={
'client_id': config['client_id'],
'client_secret': config['client_secret'],
'code': code,
'grant_type': 'authorization_code',
'redirect_uri': redirect_uri,
},
timeout=15,
)
token_response.raise_for_status()
token_data = token_response.json()
if token_data.get('error'):
raise ValueError(token_data.get('error_description') or token_data['error'])
access_token = token_data.get('access_token')
if not access_token:
raise ValueError('GitLab did not return an access token')
profile = cls._gitlab_get(access_token, '/user')
connection = SourceConnection.query.filter_by(user_id=user_id, provider='gitlab').first()
if not connection:
connection = SourceConnection(user_id=user_id, provider='gitlab')
db.session.add(connection)
connection.provider_account_id = str(profile.get('id') or '')
connection.provider_username = profile.get('username') or ''
connection.display_name = profile.get('name') or profile.get('username') or ''
connection.avatar_url = profile.get('avatar_url') or ''
connection.access_token_encrypted = encrypt_secret(access_token)
connection.scope = token_data.get('scope') or ' '.join(cls.GITLAB_SCOPES)
connection.updated_at = datetime.utcnow()
connection.last_used_at = datetime.utcnow()
db.session.commit()
return connection
@classmethod
def get_gitlab_status(cls, user_id):
connection = cls.get_connection(user_id, 'gitlab')
return {
'configured': cls.get_gitlab_config()['configured'],
'connection': connection.to_dict() if connection else None,
}
@classmethod
def list_gitlab_repositories(cls, user_id, search='', page=1, per_page=50):
token = cls._get_gitlab_token(user_id)
params = {
'membership': 'true',
'order_by': 'updated_at',
'sort': 'desc',
'page': max(int(page or 1), 1),
'per_page': min(max(int(per_page or 50), 1), 100),
}
search_text = (search or '').strip()
if search_text:
params['search'] = search_text
repos = cls._gitlab_get(token, f'/projects?{urlencode(params)}')
connection = cls.get_connection(user_id, 'gitlab')
if connection:
connection.last_used_at = datetime.utcnow()
db.session.commit()
return [cls._gitlab_repo_payload(repo) for repo in repos]
@classmethod
def list_gitlab_branches(cls, user_id, full_name):
token = cls._get_gitlab_token(user_id)
project_id = cls._gitlab_project_id(full_name)
path = f'/projects/{project_id}/repository/branches?per_page=100'
branches = cls._gitlab_get(token, path)
return [
{
'name': branch.get('name'),
'protected': branch.get('protected', False),
}
for branch in branches
]
@classmethod
def get_gitlab_repository_manifest(cls, user_id, full_name, ref=None):
token = cls._get_gitlab_token(user_id)
project_id = cls._gitlab_project_id(full_name)
root_files = cls._gitlab_list_root_files(token, project_id, ref)
file_map = {}
for file_name in RepositoryManifestService.supported_files():
if root_files and file_name not in root_files:
continue
content = cls._gitlab_get_file_content(token, project_id, file_name, ref)
if content is not None:
file_map[file_name] = content
manifest = RepositoryManifestService.analyze_files(file_map, root_files=root_files)
connection = cls.get_connection(user_id, 'gitlab')
if connection:
connection.last_used_at = datetime.utcnow()
db.session.commit()
return manifest
@classmethod
def _get_github_token(cls, user_id):
connection = cls.get_connection(user_id, 'github')
@@ -293,6 +474,93 @@ class SourceConnectionService:
raw = base64.b64decode(data.get('content') or '', validate=False)
return raw.decode('utf-8', errors='replace')
@classmethod
def _get_gitlab_token(cls, user_id):
connection = cls.get_connection(user_id, 'gitlab')
if not connection:
raise ValueError('GitLab is not connected')
return cls._decrypt_connection_token(connection)
@classmethod
def _gitlab_get(cls, token, path):
response = requests.get(
f'{cls.GITLAB_API_URL}{path}',
headers=cls._gitlab_headers(token),
timeout=20,
)
response.raise_for_status()
return response.json()
@staticmethod
def _gitlab_headers(token):
return {
'Accept': 'application/json',
'Authorization': f'Bearer {token}',
}
@staticmethod
def _gitlab_repo_path(full_name):
path = (full_name or '').strip().strip('/')
if '/' not in path:
raise ValueError('repository_full_name must be namespace/project')
return path
@classmethod
def _gitlab_project_id(cls, full_name):
return quote(cls._gitlab_repo_path(full_name), safe='')
@classmethod
def _gitlab_list_root_files(cls, token, project_id, ref=None):
params = {'per_page': 100}
if ref:
params['ref'] = ref
response = requests.get(
f'{cls.GITLAB_API_URL}/projects/{project_id}/repository/tree',
headers=cls._gitlab_headers(token),
params=params,
timeout=20,
)
response.raise_for_status()
data = response.json()
if not isinstance(data, list):
return []
return [item.get('name') for item in data if item.get('type') == 'blob' and item.get('name')]
@classmethod
def _gitlab_get_file_content(cls, token, project_id, file_name, ref=None):
params = {'ref': ref or 'HEAD'}
response = requests.get(
f'{cls.GITLAB_API_URL}/projects/{project_id}/repository/files/{quote(file_name, safe="")}/raw',
headers=cls._gitlab_headers(token),
params=params,
timeout=20,
)
if response.status_code == 404:
return None
response.raise_for_status()
return response.text
@staticmethod
def _gitlab_repo_payload(repo):
namespace = repo.get('namespace') or {}
return {
'id': repo.get('id'),
'name': repo.get('name'),
'full_name': repo.get('path_with_namespace'),
'description': repo.get('description'),
'private': repo.get('visibility') != 'public',
'fork': bool(repo.get('forked_from_project')),
'archived': repo.get('archived', False),
'default_branch': repo.get('default_branch') or 'main',
'html_url': repo.get('web_url'),
'language': None,
'updated_at': repo.get('last_activity_at'),
'owner': {
'login': namespace.get('path') or namespace.get('full_path'),
'avatar_url': repo.get('avatar_url') or namespace.get('avatar_url'),
},
}
@staticmethod
def _split_full_name(full_name):
parts = (full_name or '').strip().split('/')
+217
View File
@@ -0,0 +1,217 @@
# ServerKit Connections Roadmap
*Turning Settings → Connections from a 3-provider tab into the **single front door** for every external account ServerKit already runs on — source hosts, cloud servers, DNS, registrars, mail and storage — and the one place to answer "what do I own, what's it connected to, and when does it lapse."*
---
## The thesis: surface and unify, not build from zero
The Connections hub looks like a thin GitHub-plus-DNS tab. It isn't. ServerKit **already** talks to most of these providers — but the credentials and the capability are scattered across **four storage locations and three different pages**, so none of it reads as "your connected accounts in one place."
| Account type | Backend today | Where creds live | Powers | In the hub? |
|---|---|---|---|---|
| **GitHub** source | `source_connection_service.py` (OAuth) | Fernet-encrypted (`SourceConnection.access_token_encrypted`) | New Service from a repo | ✅ live |
| **Cloudflare / Route 53** DNS | `dns_provider_service.py` (dispatcher) | ⚠️ **plaintext** (`DNSProviderConfig`) | Wildcard SSL, email DNS, custom-domain attach | ✅ live |
| **Cloud servers** (DigitalOcean / Hetzner / Vultr / Linode) | `cloud_provisioning_service.py` + `models/cloud_server.py` + `api/cloud_provisioning.py` | (in cloud-provisioning config) | **Servers** page | ❌ not surfaced |
| **S3 / Backblaze B2** object storage | `storage_provider_service.py` (boto3) | ⚠️ masked in `storage.json`, **not** Fernet | **Backups** page | ❌ not surfaced |
| **Domain registrar / expiry** | — **none** — | — | **Domains** page | ❌ doesn't exist |
Three consequences fall out of that table:
1. **Two of the user's three asks are mostly *wiring*, not building.** "DigitalOcean to manage servers" → the provisioning backend exists; it's just never been surfaced in the hub. "S3 in the Files app" → the S3 backend exists (it runs backups); it's never been pointed at the file browser.
2. **One ask is genuinely net-new: "when do my domains expire?"** `models/domain.py` only tracks `ssl_expires_at` (certificate expiry). There is no registrar, no registration/expiry date, no WHOIS anywhere in the models. That's the headline new feature, and it's the one that delivers the "all this info in one place" payoff.
3. **There's a real security gap to close on the way through.** `utils/crypto.py` (`encrypt_secret`/`decrypt_secret`, Fernet) is already used by source connections, AI, the agent fleet and pairing — but DNS and storage secrets sit in plaintext / lightly-masked. Every new provider should encrypt at rest, and the existing two should be migrated.
**So the fast path to "one place for everything" is surfacing what exists, completing the catalog's own "coming soon" tiles, then building the single genuinely-missing system (registrar + expiry).**
---
## ✅ Update (2026-06-15): Phases 1–3 shipped
Built in one session and verified — `npm run build` (frontend, clean), `eslint` (clean), `py_compile` + venv import (backend, clean). Runtime click-through with real provider credentials is still the operator's to do.
- **Phase 1 — surface what exists:** #2 Infrastructure category (DigitalOcean / Hetzner / Vultr / Linode → existing `/cloud`), #3 S3 + Backblaze B2 storage cards (→ existing `/backups/storage`), #4 cross-links on every connected card ("N servers — Servers →", "N domains — Domains →", bucket → Backups, "New service →").
- **Phase 2 — complete the tiles:** #5 GitLab source (full OAuth mirror of GitHub, 9 routes), #6 DigitalOcean DNS, #7 GoDaddy DNS (both as new dispatch branches in `dns_provider_service.py`). **#8 SMTP relay — not built**; it needs mail-server outbound-relay wiring (a separate subsystem), so it stays a "coming soon" tile.
- **Phase 3 — domain ownership & expiry:** #9 `RegistrarConnection` model (**Fernet-encrypted** from the start), #10 `RegistrarService` (GoDaddy portfolio + expiry), #11 *not needed as written* — expiry is sourced **live** from the registrar, so no `domains`-table migration was required, #12 the `RegistrarPortfolio` panel on the Domains page ("N days left", colour-coded by urgency, Sync button), #13 registrar card shows "N domains · M expiring ≤30d". **Namecheap** intentionally left "coming soon" (XML API + IP allow-listing).
New backend: `models/registrar_connection.py`, `services/registrar_service.py`, `api/registrars.py` (`/api/v1/registrars`, registered). New/changed frontend: expanded `providerCatalog.js` (6 categories, 16 tiles), rebuilt `ConnectProviderModal.jsx` (5 provider kinds), `ConnectionsHub.jsx`, `ProviderCard.jsx` cross-links, `ProviderBrands.jsx` icons, `components/domains/RegistrarPortfolio.jsx`, `services/api/connections.js`.
**Still open:** #8 (SMTP), Namecheap registrar, #14/#15 (S3 in the Files app), and Phase 5 #16/#17 (encrypt the *pre-existing* DNS + storage plaintext secrets — note the new registrar secrets are already encrypted).
---
## How to read this document
- Tasks are **globally numbered (#1 … #18)** so they're individually addressable — e.g. "do #9".
- Tasks are grouped into **Phases 0 → 5**, ordered so cheap, high-trust wiring lands before net-new systems. Phases are independently shippable and can be reordered.
- Each task lists **Today** (what exists, with paths), **Do** (the concrete change), **Reuse** (what to lean on), and **Done when** (acceptance).
**Effort legend** (single-developer estimate):
| Tag | Meaning |
|---|---|
| **S** | ≤ 2 hours — mostly wiring |
| **M** | ~ half a day to a day |
| **L** | 1–3 days |
| **XL** | multi-day epic; split before starting |
**Status legend:**
| Mark | Meaning |
|---|---|
| 🐛 | Security / correctness gap — worth fixing regardless of roadmap order |
| 🟡 | **Almost there** — the backend or model already exists; this is glue |
| ❌ | Net-new build |
---
## Target: the connections hub as one front door
Every external account in one categorized surface, each card showing **status + access scope**, and each linking to the page it powers:
`Source code` · `Infrastructure (servers)` · `DNS & domains` · `Registrars & ownership` · `Email & delivery` · `Storage & backups`
And the cross-links that make it "one place": a **DigitalOcean** card that says *"3 servers — Manage in Servers"*, an **S3** card that says *"Connected — Browse in Files · Used by Backups"*, a **GoDaddy** card that says *"12 domains · 2 expiring within 30 days — View in Domains."*
---
## Phase 0 — Declutter the hub (do first)
### #1 — Remove the redundant hub intro `[S]` 🟡 — ✅ Done
- **Today:** `ConnectionsHub.jsx` rendered a full intro block — accent icon + `<h2>Connections</h2>` + a description paragraph — directly under the sidebar's already-active **Connections** nav label. The heading duplicated the nav; sibling tabs (e.g. `NotificationsTab`) carry no such intro.
- **Do:** Delete the intro block so the first category leads the surface.
- **Landed:** Removed the `connections-hub__intro` block (icon + `<h2>` + `<p>`), the now-unused `connectedCount` computation and `Link2` import from `frontend/src/components/settings/connections/ConnectionsHub.jsx`, and the `&__intro` / `&__intro-icon` / `&__intro-text` rules from `frontend/src/styles/pages/_connections.scss`. `npm run eslint` on the component returns clean.
- **Follow-up (optional):** if the page still feels text-heavy, the per-category blurbs (`providerCatalog.js` `CONNECTION_CATEGORIES[].blurb`) are the next thing to trim — but they group rather than duplicate, so they earn their place.
---
## Phase 1 — Surface what already exists (glue, fast wins)
The highest value-per-hour in the whole roadmap: the backends are built and tested; they're just not in the hub.
### #2 — Add an **Infrastructure** category that surfaces cloud-server providers `[M]` 🟡
- **Today:** `cloud_provisioning_service.py`, `api/cloud_provisioning.py` and `models/cloud_server.py` already exist and are registered in `app/__init__.py` — ServerKit can talk to DigitalOcean / Hetzner / Vultr / Linode — but there is **no entry point in Connections**. (Verify the exact provider list + whether it supports *listing/importing existing* machines vs. only *creating* them, in `cloud_provisioning_service.py`, before wiring the card copy.)
- **Do:** Add a `{ key: 'infra', label: 'Infrastructure', blurb: 'Cloud accounts ServerKit can provision and manage servers in.' }` category to `providerCatalog.js`, plus DigitalOcean / Hetzner / Vultr / Linode provider entries with `kind: 'cloud'`. Give `ConnectProviderModal` a `cloud` branch: save the provider API token, then show a count + a "Manage in Servers" link.
- **Reuse:** `cloud_provisioning_service`, the existing `ProviderCard`/`ConnectProviderModal` shell, `ProviderBrands.jsx` (already imports `SiDigitalocean`).
- **Done when:** A user can paste a DigitalOcean token in Connections, see "Connected," and reach server provisioning from the card.
### #3 — Surface **S3 / Backblaze B2** storage in the hub `[S]` 🟡
- **Today:** `storage_provider_service.py` drives S3-compatible (AWS, Wasabi, MinIO, DO Spaces) + Backblaze B2 for offsite backups, configured only on the **Backups** page's "Storage" tab. The catalog already has a dimmed `s3` tile.
- **Do:** Flip the `s3` tile to live and add a Backblaze B2 tile, both reading/writing the **same** storage config the Backups tab uses (`PUT /backups/storage`). Card subtitle shows the bucket + "Used by Backups."
- **Reuse:** `storage_provider_service`, `api/backups.py` storage routes, `Backups.jsx` `PROVIDER_LABELS`.
- **Done when:** Storage credentials can be set from Connections *or* Backups interchangeably (one source of truth), test-connection works from the card.
### #4 — Cross-link every surfaced card to the page it powers `[S]` 🟡
- **Today:** Cards are self-contained; nothing tells you a connection feeds Servers / Files / Backups / Domains.
- **Do:** Add an optional `manageHref` + count to the card summary (e.g. "3 servers", "12 domains, 2 expiring"). Render a quiet link in `conn-card__meta`.
- **Done when:** Each connected card answers "what is this connected to?" at a glance — the core of the "one place" promise.
---
## Phase 2 — Complete the catalog's own "coming soon" tiles
These four are already drawn as dimmed tiles; the patterns to make them real are known.
### #5 — **GitLab** source connections `[M]` 🟡
- **Today:** `providerCatalog.js` already lists GitLab as `comingSoon`. GitHub's whole flow lives in `source_connection_service.py` (OAuth → Fernet-encrypted token → repo/branch/manifest listing) behind `api/source_connections.py`.
- **Do:** Replicate the GitHub methods for GitLab (different OAuth + API base + `PRIVATE-TOKEN`/`Bearer` header), accept `'gitlab'` in `models/source_connection.py`, add the mirrored routes, flip the tile to `kind: 'github'`-style with a GitLab OAuth-app admin form.
- **Reuse:** Everything in the GitHub path; the model's `(user_id, provider)` unique constraint already supports multiple providers per user. Cross-check `components/git/GitProviders.jsx` for any existing multi-provider scaffolding before duplicating.
- **Done when:** A user connects GitLab and picks repos on New Service exactly like GitHub.
### #6 — **DigitalOcean DNS** `[S]` 🟡
- **Today:** `dns_provider_service.py` dispatches on a `provider` string with `_cloudflare_*` / `_route53_*` methods; the API routes (`/email/dns-providers`) are already generic. Catalog has a dimmed `digitalocean` DNS tile.
- **Do:** Add `_digitalocean_*` branches (`https://api.digitalocean.com/v2/domains`, `Authorization: Bearer`) for list-zones / set-record / delete-record, and a form in `ConnectProviderModal`.
- **Reuse:** The dispatcher + `find_zone_for_domain` / `ensure_a_record` already power wildcard SSL and custom-domain attach — DO DNS inherits all of it for free.
- **Done when:** A DO-hosted domain can receive auto-created A/wildcard records like Cloudflare does.
### #7 — **GoDaddy DNS** `[M]` 🟡
- **Today:** Same dispatcher as #6. GoDaddy is also a **registrar** (see Phase 3) — start with its DNS role here.
- **Do:** Add `_godaddy_*` branches (`https://api.godaddy.com/v1/domains`, `Authorization: sso-key <key>:<secret>` → reuse the Route 53 two-field form shape), and a catalog entry.
- **Done when:** GoDaddy-hosted DNS records are managed through the same flow.
### #8 — **SMTP relay** for outbound mail `[M]` ❌
- **Today:** The `email` category exists with a dimmed `smtp` tile; the mail server has no outbound-relay connection object.
- **Do:** Add an `email`-kind connect form capturing host/port/user/pass/TLS (presets for Postmark / SES / Mailgun) and store encrypted; wire it as the relay for the mail server.
- **Done when:** Outbound mail can route through a configured third-party relay.
---
## Phase 3 — Domain ownership & expiry (the headline net-new: *"when do they expire?"*)
The one system that genuinely doesn't exist. This is what turns Domains from "DNS + certs" into "the portfolio of names you own, and when they lapse."
### #9 — `RegistrarConnection` model + encrypted credentials `[M]` ❌
- **Today:** No registrar concept anywhere.
- **Do:** New `models/registrar_connection.py` mirroring `SourceConnection`: `user_id`, `provider` (`godaddy`/`namecheap`/…), `provider_account_id`, `api_key_encrypted`, `api_secret_encrypted`, timestamps. Store secrets via `encrypt_secret` from the start (don't repeat the DNS plaintext mistake).
- **Reuse:** `utils/crypto.py`, the `SourceConnection` shape.
- **Done when:** A registrar account can be connected and its credentials are encrypted at rest.
### #10 — `RegistrarService` (GoDaddy, Namecheap) `[L]` ❌
- **Do:** Dispatcher service (`if provider == 'godaddy': _godaddy_list_domains()` …) exposing `list_domains()`, `check_expiry(domain)`, and later `update_nameservers()` / `renew_domain()`. GoDaddy: `GET /v1/domains`. Namecheap: `namecheap.domains.getList`.
- **Reuse:** The exact dispatcher shape from `dns_provider_service.py`.
- **Done when:** Given a connected registrar, the service returns each domain's registration + expiry date.
### #11 — Extend the `Domain` model with ownership fields `[S]` ❌
- **Today:** `models/domain.py` has only `ssl_expires_at` (cert), `ssl_auto_renew`, nginx/cert paths.
- **Do:** Add `registrar` (str), `registrar_connection_id` (FK), `registration_expires_at` (DateTime), `auto_renew_enabled` (bool), `nameservers` (JSON), `last_synced_at` (DateTime). Update `to_dict()`.
- **Done when:** A domain row can hold *who it's registered with and when it expires*, distinct from its cert.
### #12 — Expiry sync + the "expires in N days" UI `[M]` ❌
- **Do:** A periodic job (reuse the backup-schedule/cron mechanism) that calls `RegistrarService.check_expiry` for every domain with a `registrar_connection_id` and writes `registration_expires_at` + `last_synced_at`. On `pages/Domains.jsx`, show **expires in N days**, a warning pill under 30 days, auto-renew status, and a manual "Sync now."
- **Reuse:** Notification channels (#backups already proves the cron + notify path) for "domain X expires in 14 days" alerts.
- **Done when:** The Domains page is a true portfolio view: every name, its registrar, its expiry, color-coded by urgency.
### #13 — Registrar status on the Connections card `[S]` ❌
- **Do:** A "Registrars & ownership" category in the hub; GoDaddy/Namecheap cards show "12 domains · 2 expiring ≤30d — View in Domains" (the #4 cross-link, applied).
- **Done when:** The hub answers "is my domain ownership healthy?" without opening Domains.
---
## Phase 4 — S3 as a first-class Files target (*"S3 connects in the files app"*)
### #14 — `/files/s3/*` endpoints over the storage client `[M]` ❌
- **Today:** `file_service.py` / `api/files.py` are local-filesystem only (`ALLOWED_ROOTS`); no remote/pluggable backend. `storage_provider_service.py` already holds a working boto3 client.
- **Do:** Add `GET /files/s3/browse`, `/read`, `POST /files/s3/write`, `DELETE /files/s3/delete`, `GET /files/s3/info` that map `list_objects_v2` / `get` / `put` / `delete` into the same entry shape `browse` returns (virtual folders from `/`-suffixed prefixes).
- **Reuse:** The Phase 1 (#3) storage credentials — no new creds, no new config.
- **Done when:** The API can list and transfer bucket objects in the File Manager's data shape.
### #15 — S3 bucket in the File Manager target picker `[M]` ❌
- **Do:** Add an "S3 bucket" option to `FileManager.jsx`'s target picker (next to Local + agents). When active, browse by prefix and **disable** ops S3 can't do (mkdir, rename, chmod).
- **Done when:** A user browses, uploads to, downloads from and deletes in their bucket from the Files app — the literal ask.
---
## Phase 5 — Harden & unify credentials (security)
### #16 — Encrypt DNS + storage secrets at rest `[M]` 🐛
- **Today:** `DNSProviderConfig.api_key`/`api_secret` are plaintext; `storage.json` is only masked-on-read. `encrypt_secret`/`decrypt_secret` already exist and are used by source connections, AI, server and pairing.
- **Do:** Wrap reads/writes of DNS + storage + (new) registrar secrets in `encrypt_secret`/`decrypt_secret`.
- **Done when:** No third-party secret is persisted in plaintext.
### #17 — Migrate existing plaintext secrets `[S]` 🐛
- **Do:** One-time migration that encrypts any already-stored DNS/storage secrets and records the migration in the migration-history surface.
- **Done when:** Existing installs are encrypted with zero user action.
### #18 — (Optional) one credential abstraction `[L]` ❌
- **Do:** Fold the four credential homes (SourceConnection, DNSProviderConfig, storage.json, cloud-provisioning config) behind a single `Connection` interface so every provider stores/scopes/encrypts identically and the hub reads one list.
- **Done when:** Adding a provider is one declarative entry + one service, with storage/scoping/encryption inherited.
---
## Future / nice-to-have
- **More source hosts:** Bitbucket, Gitea/Forgejo (replicate #5).
- **More registrars:** Cloudflare Registrar, Porkbun, Namecheap-resellers (extend #10).
- **More storage:** Cloudflare R2 / DigitalOcean Spaces / Wasabi presets (free — they're S3-compatible), then GCS / Azure Blob (new clients in `storage_provider_service`).
- **Import existing cloud servers** (not just provision new) — list a provider's droplets/instances and adopt them into the fleet, if `cloud_provisioning_service` doesn't already (verify).
- **Scoping policy:** decide per-category whether a connection is **per-user** (like GitHub) or **admin-wide** (like DNS) — registrars likely per-user, infra likely admin.
---
## Suggested order
1. **#1** ✅ (done) → **#2 #3 #4** — surface servers + S3 + cross-links. Biggest visible payoff for the least code; directly answers "DigitalOcean servers" and "S3."
2. **#9–#13** — domain registrar + expiry. The headline net-new "when do they expire," and the strongest "all in one place" moment.
3. **#14 #15** — S3 in Files.
4. **#5–#8** — finish the remaining catalog tiles as appetite allows.
5. **#16 #17** — encrypt-at-rest (pull earlier if any of this ships to production first).
@@ -0,0 +1,84 @@
// The domain portfolio — every domain owned at a connected registrar (GoDaddy,
// …) with its registration expiry. Sourced live from /registrars/domains so it
// works even for domains not yet attached to an app. Renders nothing until a
// registrar is connected (and thus at least one domain comes back), so it stays
// invisible on installs that don't use the feature.
import { useEffect, useState, useCallback } from 'react';
import { Globe, RefreshCw, AlertTriangle } from 'lucide-react';
import api from '../../services/api';
function expiryTone(days) {
if (days == null) return 'neutral';
if (days < 0) return 'expired';
if (days <= 14) return 'danger';
if (days <= 30) return 'warn';
return 'ok';
}
export default function RegistrarPortfolio() {
const [domains, setDomains] = useState(null); // null = loading
const [syncing, setSyncing] = useState(false);
const load = useCallback(async (force) => {
try {
const data = force ? await api.syncRegistrarDomains() : await api.getRegistrarDomains();
setDomains(data.domains || []);
} catch {
setDomains([]);
}
}, []);
useEffect(() => { load(false); }, [load]);
async function refresh() {
setSyncing(true);
try { await load(true); } finally { setSyncing(false); }
}
// Invisible until at least one registrar domain exists.
if (!domains || domains.length === 0) return null;
const expiringSoon = domains.filter((d) => d.days_until_expiry != null && d.days_until_expiry <= 30).length;
return (
<section className="reg-portfolio">
<header className="reg-portfolio__head">
<div className="reg-portfolio__title">
<Globe size={16} />
<h2>Registered domains</h2>
<span className="reg-portfolio__count">{domains.length}</span>
{expiringSoon > 0 && (
<span className="reg-portfolio__warn"><AlertTriangle size={13} /> {expiringSoon} expiring ≤30d</span>
)}
</div>
<button type="button" className="reg-portfolio__refresh" onClick={refresh} disabled={syncing}>
<RefreshCw size={14} className={syncing ? 'is-spinning' : ''} /> {syncing ? 'Syncing…' : 'Sync'}
</button>
</header>
<div className="reg-portfolio__list">
{domains.map((d) => {
const tone = expiryTone(d.days_until_expiry);
const daysLabel = d.days_until_expiry == null ? '—'
: d.days_until_expiry < 0 ? 'Expired'
: `${d.days_until_expiry}d left`;
return (
<div key={`${d.registrar}:${d.domain}`} className="reg-portfolio__row">
<span className="reg-portfolio__domain">{d.domain}</span>
<span className="reg-portfolio__via">{d.registrar_name || d.registrar}</span>
<span className={`reg-portfolio__expiry reg-portfolio__expiry--${tone}`}>{daysLabel}</span>
<span className="reg-portfolio__date">
{d.expires_at ? new Date(d.expires_at).toLocaleDateString() : ''}
</span>
{d.auto_renew != null && (
<span className={`reg-portfolio__renew${d.auto_renew ? ' is-on' : ''}`}>
{d.auto_renew ? 'Auto-renew' : 'Manual'}
</span>
)}
</div>
);
})}
</div>
</section>
);
}
@@ -1,24 +1,37 @@
// Brand-authentic icons for external connection providers — the single source of
// truth for how an integration (GitHub, Cloudflare, AWS/Route 53, …) is presented
// across the Connections hub. Mirrors components/icons/DatabaseBrands.jsx and
// components/git/GitProviders.jsx: we wrap Simple Icons (via react-icons) so each
// provider is instantly recognizable instead of sharing one generic plug glyph.
// truth for how an integration (GitHub, Cloudflare, DigitalOcean, GoDaddy, …) is
// presented across the Connections hub. Mirrors components/icons/DatabaseBrands.jsx
// and components/git/GitProviders.jsx: we wrap Simple Icons (via react-icons) so
// each provider is instantly recognizable instead of sharing one generic glyph.
//
// Simple Icons render with `fill="currentColor"`, so the surrounding SCSS controls
// the color with no inline styles.
import { SiGithub, SiGitlab, SiCloudflare, SiDigitalocean } from 'react-icons/si';
import {
SiGithub, SiGitlab, SiCloudflare, SiDigitalocean,
SiHetzner, SiVultr, SiAkamai, SiGodaddy, SiNamecheap, SiBackblaze,
} from 'react-icons/si';
import { FaAws } from 'react-icons/fa';
import { Mail, HardDrive, Plug } from 'lucide-react';
// provider id (matches providerCatalog) -> brand icon component.
// provider id (matches providerCatalog) -> brand icon component. Several ids map
// to the same brand wearing two hats (e.g. DigitalOcean as both a cloud host and
// a DNS provider; GoDaddy as both DNS and registrar).
const PROVIDER_ICONS = {
github: SiGithub,
gitlab: SiGitlab,
cloudflare: SiCloudflare,
route53: FaAws,
digitalocean: SiDigitalocean,
digitalocean_dns: SiDigitalocean,
hetzner: SiHetzner,
vultr: SiVultr,
linode: SiAkamai, // Linode is now Akamai
godaddy: SiGodaddy,
godaddy_dns: SiGodaddy,
namecheap: SiNamecheap,
smtp: Mail,
s3: HardDrive,
b2: SiBackblaze,
};
// Renders the brand icon for a provider, falling back to the generic lucide Plug
@@ -1,80 +1,40 @@
// The connect / manage dialog for a single provider. Switches on `provider.kind`:
// - 'github' → per-user OAuth connect + (admin) OAuth-app credentials
// - 'dns' → list existing API-key connections + an add form. For Cloudflare
// the add form leads with the access-level choice the user asked
// for: a least-privilege scoped token vs a full-account global key.
import { useEffect, useState } from 'react';
// - 'source' → per-user OAuth connect + (admin) OAuth-app credentials (GitHub, GitLab)
// - 'dns' → list API-key connections + add (Cloudflare scoped/global, Route 53,
// DigitalOcean token, GoDaddy key+secret)
// - 'cloud' → connect a provider API token; servers are managed on the Servers page
// - 'storage' → S3-compatible / Backblaze B2 bucket credentials (offsite backups)
// - 'registrar' → GoDaddy account; reads the domain portfolio + expiry
// Each body owns its own form state and is keyed by provider.id so switching
// providers resets cleanly.
import { useState } from 'react';
import {
CheckCircle2, ExternalLink, KeyRound, Link2, PlugZap, ShieldCheck, ShieldAlert, Trash2,
CheckCircle2, ExternalLink, KeyRound, Link2, Mail, PlugZap, Server, Globe,
HardDrive, ShieldCheck, ShieldAlert, Trash2,
} from 'lucide-react';
import { Link } from 'react-router-dom';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { ProviderBrandIcon } from '../../icons/ProviderBrands';
import { deriveScope } from './providerCatalog';
const EMPTY_FORM = { name: '', api_key: '', api_secret: '', api_email: '' };
export default function ConnectProviderModal({
provider, open, onOpenChange,
githubStatus, githubConfig, dnsProviders, isAdmin,
onConnectGithub, onDisconnectGithub, onSaveGithubConfig,
provider, open, onOpenChange, isAdmin,
sourceStatus, sourceConfig, dnsProviders, cloudProviders, storageConfig, registrarConnections, relayConfig,
onConnectSource, onDisconnectSource, onSaveSourceConfig,
onAddDns, onRemoveDns, onTestDns,
onAddCloud, onRemoveCloud,
onSaveStorage, onTestStorage,
onAddRegistrar, onRemoveRegistrar, onTestRegistrar,
onSaveRelay, onTestRelay, onDisableRelay,
}) {
const [cfMode, setCfMode] = useState('scoped'); // 'scoped' | 'global'
const [form, setForm] = useState(EMPTY_FORM);
const [ghConfig, setGhConfig] = useState({ client_id: '', client_secret: '' });
const [busy, setBusy] = useState(false);
// Reset form state whenever the dialog opens for a (possibly different) provider.
useEffect(() => {
if (!provider) return;
setForm({ ...EMPTY_FORM, name: provider.name === 'Route 53' ? 'Route 53' : provider.name });
setCfMode('scoped');
setGhConfig({
client_id: githubConfig?.client_id || '',
client_secret: githubConfig?.client_secret || '',
});
}, [provider, githubConfig]);
if (!provider) return null;
const callbackUrl = `${window.location.origin}/connections/callback/github`;
const isCloudflare = provider.provider === 'cloudflare';
const isRoute53 = provider.provider === 'route53';
const connections = (dnsProviders || []).filter((p) => p.provider === provider.provider);
// Add-form validity per provider / access level.
const canAdd = (() => {
if (!form.api_key.trim()) return false;
if (isCloudflare && cfMode === 'global' && !form.api_email.trim()) return false;
if (isRoute53 && !form.api_secret.trim()) return false;
return true;
})();
async function withBusy(fn) {
setBusy(true);
try { return await fn(); } finally { setBusy(false); }
}
async function handleSaveGh(e) {
e.preventDefault();
await withBusy(() => onSaveGithubConfig(ghConfig));
}
async function handleAddDns(e) {
e.preventDefault();
const base = { name: form.name.trim() || provider.name, provider: provider.provider, api_key: form.api_key.trim() };
let payload = base;
if (isCloudflare && cfMode === 'global') payload = { ...base, api_email: form.api_email.trim() };
if (isRoute53) payload = { ...base, api_secret: form.api_secret.trim() };
const ok = await withBusy(() => onAddDns(payload));
if (ok) setForm({ ...EMPTY_FORM, name: provider.name === 'Route 53' ? 'Route 53' : provider.name });
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="conn-modal">
@@ -89,113 +49,53 @@ export default function ConnectProviderModal({
</DialogHeader>
<div className="conn-modal__body">
{provider.kind === 'github' && (
<GithubBody
status={githubStatus} isAdmin={isAdmin} callbackUrl={callbackUrl}
ghConfig={ghConfig} setGhConfig={setGhConfig} busy={busy}
onConnect={onConnectGithub} onDisconnect={onDisconnectGithub} onSaveConfig={handleSaveGh}
docUrl={provider.docUrl}
{provider.kind === 'source' && (
<SourceBody
key={provider.id} provider={provider} isAdmin={isAdmin}
status={sourceStatus?.[provider.provider]} config={sourceConfig?.[provider.provider]}
onConnect={() => onConnectSource(provider)}
onDisconnect={() => onDisconnectSource(provider)}
onSaveConfig={(cfg) => onSaveSourceConfig(provider, cfg)}
/>
)}
{provider.kind === 'dns' && (
<>
{connections.length > 0 && (
<div className="conn-list">
{connections.map((c) => {
const scope = deriveScope(c);
return (
<div key={c.id} className="conn-list__row">
<div className="conn-list__info">
<strong>{c.name}</strong>
<span className="conn-list__key">{c.api_key}</span>
</div>
{scope && <span className={`conn-pill conn-pill--${scope.tone}`} title={scope.hint}>{scope.label}</span>}
{isAdmin && (
<div className="conn-list__actions">
<Button type="button" size="sm" variant="outline" disabled={busy} onClick={() => withBusy(() => onTestDns(c.id))}>Test</Button>
<Button type="button" size="sm" variant="ghost" disabled={busy} onClick={() => withBusy(() => onRemoveDns(c))} aria-label={`Remove ${c.name}`}><Trash2 size={15} /></Button>
</div>
)}
</div>
);
})}
</div>
)}
<DnsBody
key={provider.id} provider={provider} isAdmin={isAdmin}
connections={(dnsProviders || []).filter((p) => p.provider === provider.provider)}
onAdd={onAddDns} onRemove={onRemoveDns} onTest={onTestDns}
/>
)}
{!isAdmin ? (
<p className="conn-modal__note"><ShieldCheck size={15} /> Only administrators can add or change connections.</p>
) : (
<form className="conn-form" onSubmit={handleAddDns}>
<div className="conn-form__heading">{connections.length > 0 ? 'Add another connection' : 'Connect ' + provider.name}</div>
{provider.kind === 'cloud' && (
<CloudBody
key={provider.id} provider={provider} isAdmin={isAdmin}
connections={(cloudProviders || []).filter((p) => p.provider_type === provider.providerType)}
onAdd={onAddCloud} onRemove={onRemoveCloud}
/>
)}
{isCloudflare && (
<div className="conn-scope" role="radiogroup" aria-label="Access level">
<button type="button" className={`conn-scope__opt${cfMode === 'scoped' ? ' is-active' : ''}`} onClick={() => setCfMode('scoped')} role="radio" aria-checked={cfMode === 'scoped'}>
<span className="conn-scope__head"><ShieldCheck size={16} /> Scoped token <span className="conn-scope__rec">Recommended</span></span>
<span className="conn-scope__desc">A Cloudflare token limited to DNS:Edit on the zones you choose. ServerKit can only touch DNS.</span>
</button>
<button type="button" className={`conn-scope__opt${cfMode === 'global' ? ' is-active' : ''}`} onClick={() => setCfMode('global')} role="radio" aria-checked={cfMode === 'global'}>
<span className="conn-scope__head"><ShieldAlert size={16} /> Global API key</span>
<span className="conn-scope__desc">Your account email + global key. Simplest to set up, but grants full account access — we manage everything.</span>
</button>
</div>
)}
{provider.kind === 'storage' && (
<StorageBody
key={provider.id} provider={provider} isAdmin={isAdmin}
storageConfig={storageConfig} onSave={onSaveStorage} onTest={onTestStorage}
/>
)}
<div className="conn-form__grid">
<div className="form-group">
<Label htmlFor="conn-name">Connection name</Label>
<Input id="conn-name" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} placeholder={provider.name} />
</div>
{provider.kind === 'registrar' && (
<RegistrarBody
key={provider.id} provider={provider} isAdmin={isAdmin}
connections={(registrarConnections || []).filter((c) => c.provider === provider.provider)}
onAdd={onAddRegistrar} onRemove={onRemoveRegistrar} onTest={onTestRegistrar}
/>
)}
{isCloudflare && cfMode === 'scoped' && (
<div className="form-group conn-form__wide">
<Label htmlFor="conn-token">API token</Label>
<Input id="conn-token" type="password" value={form.api_key} onChange={(e) => setForm((f) => ({ ...f, api_key: e.target.value }))} placeholder="Cloudflare scoped API token" autoComplete="off" />
</div>
)}
{isCloudflare && cfMode === 'global' && (
<>
<div className="form-group">
<Label htmlFor="conn-email">Account email</Label>
<Input id="conn-email" type="email" value={form.api_email} onChange={(e) => setForm((f) => ({ ...f, api_email: e.target.value }))} placeholder="you@example.com" autoComplete="off" />
</div>
<div className="form-group">
<Label htmlFor="conn-key">Global API key</Label>
<Input id="conn-key" type="password" value={form.api_key} onChange={(e) => setForm((f) => ({ ...f, api_key: e.target.value }))} placeholder="Cloudflare global API key" autoComplete="off" />
</div>
</>
)}
{isRoute53 && (
<>
<div className="form-group">
<Label htmlFor="conn-akid">Access key ID</Label>
<Input id="conn-akid" value={form.api_key} onChange={(e) => setForm((f) => ({ ...f, api_key: e.target.value }))} placeholder="AKIA…" autoComplete="off" />
</div>
<div className="form-group">
<Label htmlFor="conn-secret">Secret access key</Label>
<Input id="conn-secret" type="password" value={form.api_secret} onChange={(e) => setForm((f) => ({ ...f, api_secret: e.target.value }))} placeholder="Secret access key" autoComplete="off" />
</div>
</>
)}
</div>
{provider.docUrl && (
<a className="conn-form__doc" href={provider.docUrl} target="_blank" rel="noreferrer">
<ExternalLink size={13} /> Where do I get this?
</a>
)}
<div className="conn-form__actions">
<Button type="submit" size="sm" disabled={busy || !canAdd}>
{busy ? 'Connecting…' : 'Connect'}
</Button>
</div>
</form>
)}
</>
{provider.kind === 'email' && (
<EmailBody
key={provider.id} provider={provider} isAdmin={isAdmin}
relayConfig={relayConfig}
onSave={onSaveRelay} onTest={onTestRelay} onDisable={onDisableRelay}
/>
)}
</div>
</DialogContent>
@@ -203,9 +103,19 @@ export default function ConnectProviderModal({
);
}
function GithubBody({ status, isAdmin, callbackUrl, ghConfig, setGhConfig, busy, onConnect, onDisconnect, onSaveConfig, docUrl }) {
// ── Source: GitHub / GitLab ──
function SourceBody({ provider, status, config, isAdmin, onConnect, onDisconnect, onSaveConfig }) {
const connection = status?.connection;
const configured = status?.configured;
const callbackUrl = `${window.location.origin}/connections/callback/${provider.provider}`;
const [cfg, setCfg] = useState({ client_id: config?.client_id || '', client_secret: config?.client_secret || '' });
const [busy, setBusy] = useState(false);
async function save(e) {
e.preventDefault();
setBusy(true);
try { await onSaveConfig(cfg); } finally { setBusy(false); }
}
return (
<>
@@ -224,36 +134,36 @@ function GithubBody({ status, isAdmin, callbackUrl, ghConfig, setGhConfig, busy,
<div className="conn-empty">
<span className="conn-empty__icon"><Link2 size={18} /></span>
<div className="conn-empty__text">
<strong>{configured ? 'Connect your GitHub account' : 'GitHub OAuth is not configured yet'}</strong>
<strong>{configured ? `Connect your ${provider.name} account` : `${provider.name} OAuth is not configured yet`}</strong>
<span>{configured
? 'Authorize ServerKit once, then pick repositories directly on the New Service page.'
: 'An admin needs to add an OAuth app below before anyone can connect.'}</span>
</div>
<Button type="button" size="sm" disabled={!configured || busy} onClick={onConnect}>
<PlugZap size={15} /> Connect GitHub
<PlugZap size={15} /> Connect {provider.name}
</Button>
</div>
)}
{isAdmin && (
<form className="conn-form" onSubmit={onSaveConfig}>
<form className="conn-form" onSubmit={save}>
<div className="conn-form__heading"><KeyRound size={15} /> OAuth app credentials</div>
<div className="conn-form__grid">
<div className="form-group">
<Label htmlFor="gh-client-id">Client ID</Label>
<Input id="gh-client-id" value={ghConfig.client_id} onChange={(e) => setGhConfig((c) => ({ ...c, client_id: e.target.value }))} placeholder="GitHub OAuth client ID" autoComplete="off" />
<Label htmlFor="src-client-id">Client ID</Label>
<Input id="src-client-id" value={cfg.client_id} onChange={(e) => setCfg((c) => ({ ...c, client_id: e.target.value }))} placeholder={`${provider.name} OAuth client ID`} autoComplete="off" />
</div>
<div className="form-group">
<Label htmlFor="gh-client-secret">Client Secret</Label>
<Input id="gh-client-secret" type="password" value={ghConfig.client_secret} onChange={(e) => setGhConfig((c) => ({ ...c, client_secret: e.target.value }))} placeholder="GitHub OAuth client secret" autoComplete="off" />
<Label htmlFor="src-client-secret">Client Secret</Label>
<Input id="src-client-secret" type="password" value={cfg.client_secret} onChange={(e) => setCfg((c) => ({ ...c, client_secret: e.target.value }))} placeholder={`${provider.name} OAuth client secret`} autoComplete="off" />
</div>
</div>
<div className="conn-form__callback">
<CheckCircle2 size={14} /> Callback URL: <code>{callbackUrl}</code>
</div>
{docUrl && (
<a className="conn-form__doc" href={docUrl} target="_blank" rel="noreferrer">
<ExternalLink size={13} /> Create an OAuth app on GitHub
{provider.docUrl && (
<a className="conn-form__doc" href={provider.docUrl} target="_blank" rel="noreferrer">
<ExternalLink size={13} /> Create an OAuth app on {provider.name}
</a>
)}
<div className="conn-form__actions">
@@ -264,3 +174,474 @@ function GithubBody({ status, isAdmin, callbackUrl, ghConfig, setGhConfig, busy,
</>
);
}
// ── DNS: Cloudflare / Route 53 / DigitalOcean / GoDaddy ──
const EMPTY_DNS = { name: '', api_key: '', api_secret: '', api_email: '' };
function DnsBody({ provider, isAdmin, connections, onAdd, onRemove, onTest }) {
const isCloudflare = provider.provider === 'cloudflare';
const isRoute53 = provider.provider === 'route53';
const isDigitalOcean = provider.provider === 'digitalocean';
const isGoDaddy = provider.provider === 'godaddy';
const [cfMode, setCfMode] = useState('scoped'); // 'scoped' | 'global'
const [form, setForm] = useState({ ...EMPTY_DNS, name: provider.name });
const [busy, setBusy] = useState(false);
const canAdd = (() => {
if (!form.api_key.trim()) return false;
if (isCloudflare && cfMode === 'global' && !form.api_email.trim()) return false;
if ((isRoute53 || isGoDaddy) && !form.api_secret.trim()) return false;
return true;
})();
async function withBusy(fn) { setBusy(true); try { return await fn(); } finally { setBusy(false); } }
async function add(e) {
e.preventDefault();
const base = { name: form.name.trim() || provider.name, provider: provider.provider, api_key: form.api_key.trim() };
let payload = base;
if (isCloudflare && cfMode === 'global') payload = { ...base, api_email: form.api_email.trim() };
if (isRoute53 || isGoDaddy) payload = { ...base, api_secret: form.api_secret.trim() };
const ok = await withBusy(() => onAdd(payload));
if (ok) setForm({ ...EMPTY_DNS, name: provider.name });
}
return (
<>
{connections.length > 0 && (
<div className="conn-list">
{connections.map((c) => {
const scope = deriveScope(c);
return (
<div key={c.id} className="conn-list__row">
<div className="conn-list__info">
<strong>{c.name}</strong>
<span className="conn-list__key">{c.api_key}</span>
</div>
{scope && <span className={`conn-pill conn-pill--${scope.tone}`} title={scope.hint}>{scope.label}</span>}
{isAdmin && (
<div className="conn-list__actions">
<Button type="button" size="sm" variant="outline" disabled={busy} onClick={() => withBusy(() => onTest(c.id))}>Test</Button>
<Button type="button" size="sm" variant="ghost" disabled={busy} onClick={() => withBusy(() => onRemove(c))} aria-label={`Remove ${c.name}`}><Trash2 size={15} /></Button>
</div>
)}
</div>
);
})}
</div>
)}
{!isAdmin ? (
<p className="conn-modal__note"><ShieldCheck size={15} /> Only administrators can add or change connections.</p>
) : (
<form className="conn-form" onSubmit={add}>
<div className="conn-form__heading">{connections.length > 0 ? 'Add another connection' : `Connect ${provider.name}`}</div>
{isCloudflare && (
<div className="conn-scope" role="radiogroup" aria-label="Access level">
<button type="button" className={`conn-scope__opt${cfMode === 'scoped' ? ' is-active' : ''}`} onClick={() => setCfMode('scoped')} role="radio" aria-checked={cfMode === 'scoped'}>
<span className="conn-scope__head"><ShieldCheck size={16} /> Scoped token <span className="conn-scope__rec">Recommended</span></span>
<span className="conn-scope__desc">A Cloudflare token limited to DNS:Edit on the zones you choose. ServerKit can only touch DNS.</span>
</button>
<button type="button" className={`conn-scope__opt${cfMode === 'global' ? ' is-active' : ''}`} onClick={() => setCfMode('global')} role="radio" aria-checked={cfMode === 'global'}>
<span className="conn-scope__head"><ShieldAlert size={16} /> Global API key</span>
<span className="conn-scope__desc">Your account email + global key. Simplest to set up, but grants full account access.</span>
</button>
</div>
)}
<div className="conn-form__grid">
<div className="form-group">
<Label htmlFor="dns-name">Connection name</Label>
<Input id="dns-name" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} placeholder={provider.name} />
</div>
{(isDigitalOcean || (isCloudflare && cfMode === 'scoped')) && (
<div className="form-group conn-form__wide">
<Label htmlFor="dns-token">API token</Label>
<Input id="dns-token" type="password" value={form.api_key} onChange={(e) => setForm((f) => ({ ...f, api_key: e.target.value }))} placeholder={isDigitalOcean ? 'DigitalOcean personal access token' : 'Cloudflare scoped API token'} autoComplete="off" />
</div>
)}
{isCloudflare && cfMode === 'global' && (
<>
<div className="form-group">
<Label htmlFor="dns-email">Account email</Label>
<Input id="dns-email" type="email" value={form.api_email} onChange={(e) => setForm((f) => ({ ...f, api_email: e.target.value }))} placeholder="you@example.com" autoComplete="off" />
</div>
<div className="form-group">
<Label htmlFor="dns-key">Global API key</Label>
<Input id="dns-key" type="password" value={form.api_key} onChange={(e) => setForm((f) => ({ ...f, api_key: e.target.value }))} placeholder="Cloudflare global API key" autoComplete="off" />
</div>
</>
)}
{isRoute53 && (
<>
<div className="form-group">
<Label htmlFor="dns-akid">Access key ID</Label>
<Input id="dns-akid" value={form.api_key} onChange={(e) => setForm((f) => ({ ...f, api_key: e.target.value }))} placeholder="AKIA…" autoComplete="off" />
</div>
<div className="form-group">
<Label htmlFor="dns-secret">Secret access key</Label>
<Input id="dns-secret" type="password" value={form.api_secret} onChange={(e) => setForm((f) => ({ ...f, api_secret: e.target.value }))} placeholder="Secret access key" autoComplete="off" />
</div>
</>
)}
{isGoDaddy && (
<>
<div className="form-group">
<Label htmlFor="dns-gd-key">API key</Label>
<Input id="dns-gd-key" value={form.api_key} onChange={(e) => setForm((f) => ({ ...f, api_key: e.target.value }))} placeholder="GoDaddy API key" autoComplete="off" />
</div>
<div className="form-group">
<Label htmlFor="dns-gd-secret">API secret</Label>
<Input id="dns-gd-secret" type="password" value={form.api_secret} onChange={(e) => setForm((f) => ({ ...f, api_secret: e.target.value }))} placeholder="GoDaddy API secret" autoComplete="off" />
</div>
</>
)}
</div>
{provider.docUrl && (
<a className="conn-form__doc" href={provider.docUrl} target="_blank" rel="noreferrer">
<ExternalLink size={13} /> Where do I get this?
</a>
)}
<div className="conn-form__actions">
<Button type="submit" size="sm" disabled={busy || !canAdd}>{busy ? 'Connecting…' : 'Connect'}</Button>
</div>
</form>
)}
</>
);
}
// ── Cloud: DigitalOcean / Hetzner / Vultr / Linode (server provisioning) ──
function CloudBody({ provider, isAdmin, connections, onAdd, onRemove }) {
const [form, setForm] = useState({ name: provider.name, api_key: '' });
const [busy, setBusy] = useState(false);
async function withBusy(fn) { setBusy(true); try { return await fn(); } finally { setBusy(false); } }
async function add(e) {
e.preventDefault();
const ok = await withBusy(() => onAdd({ provider_type: provider.providerType, name: form.name.trim() || provider.name, api_key: form.api_key.trim() }));
if (ok) setForm({ name: provider.name, api_key: '' });
}
return (
<>
{connections.length > 0 && (
<div className="conn-list">
{connections.map((c) => (
<div key={c.id} className="conn-list__row">
<div className="conn-list__info">
<strong>{c.name}</strong>
<span className="conn-list__key">Connected</span>
</div>
{isAdmin && (
<div className="conn-list__actions">
<Button type="button" size="sm" variant="ghost" disabled={busy} onClick={() => withBusy(() => onRemove(c.id))} aria-label={`Disconnect ${c.name}`}><Trash2 size={15} /></Button>
</div>
)}
</div>
))}
</div>
)}
<p className="conn-modal__note">
<Server size={15} /> Once connected, provision and manage servers from <Link className="conn-modal__link" to="/servers">Servers →</Link>
</p>
{!isAdmin ? (
<p className="conn-modal__note"><ShieldCheck size={15} /> Only administrators can connect cloud accounts.</p>
) : (
<form className="conn-form" onSubmit={add}>
<div className="conn-form__heading">{connections.length > 0 ? 'Add another account' : `Connect ${provider.name}`}</div>
<div className="conn-form__grid">
<div className="form-group">
<Label htmlFor="cloud-name">Account name</Label>
<Input id="cloud-name" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} placeholder={provider.name} />
</div>
<div className="form-group conn-form__wide">
<Label htmlFor="cloud-token">API token</Label>
<Input id="cloud-token" type="password" value={form.api_key} onChange={(e) => setForm((f) => ({ ...f, api_key: e.target.value }))} placeholder={`${provider.name} API token`} autoComplete="off" />
</div>
</div>
{provider.docUrl && (
<a className="conn-form__doc" href={provider.docUrl} target="_blank" rel="noreferrer">
<ExternalLink size={13} /> Where do I get this?
</a>
)}
<div className="conn-form__actions">
<Button type="submit" size="sm" disabled={busy || !form.api_key.trim()}>{busy ? 'Connecting…' : 'Connect'}</Button>
</div>
</form>
)}
</>
);
}
// ── Storage: S3-compatible / Backblaze B2 ──
const STORAGE_FIELDS = {
s3: [
{ k: 'bucket', label: 'Bucket' },
{ k: 'region', label: 'Region', placeholder: 'us-east-1' },
{ k: 'access_key', label: 'Access key ID', placeholder: 'AKIA…' },
{ k: 'secret_key', label: 'Secret access key', secret: true },
{ k: 'endpoint_url', label: 'Endpoint URL (optional)', placeholder: 'https://… for Wasabi / MinIO / Spaces', wide: true },
{ k: 'path_prefix', label: 'Path prefix', placeholder: 'serverkit-backups' },
],
b2: [
{ k: 'bucket', label: 'Bucket' },
{ k: 'key_id', label: 'Key ID' },
{ k: 'application_key', label: 'Application key', secret: true },
{ k: 'endpoint_url', label: 'Endpoint URL', placeholder: 'https://s3.us-west-…backblazeb2.com', wide: true },
{ k: 'path_prefix', label: 'Path prefix', placeholder: 'serverkit-backups' },
],
};
function StorageBody({ provider, isAdmin, storageConfig, onSave, onTest }) {
const sp = provider.storageProvider; // 's3' | 'b2'
const fields = STORAGE_FIELDS[sp] || [];
const existing = (storageConfig && storageConfig[sp]) || {};
const [form, setForm] = useState(() => {
const init = {};
for (const f of fields) init[f.k] = existing[f.k] || '';
return init;
});
const [busy, setBusy] = useState(false);
const isActive = storageConfig?.provider === sp;
function buildConfig() {
return { ...(storageConfig || {}), provider: sp, [sp]: { ...existing, ...form } };
}
async function withBusy(fn) { setBusy(true); try { return await fn(); } finally { setBusy(false); } }
async function save(e) { e.preventDefault(); await withBusy(() => onSave(buildConfig())); }
async function test() { await withBusy(() => onTest(buildConfig())); }
if (!isAdmin) {
return <p className="conn-modal__note"><ShieldCheck size={15} /> Only administrators can configure storage.</p>;
}
return (
<>
<p className="conn-modal__note">
<HardDrive size={15} /> {isActive ? 'This is the active backup destination.' : 'Saving makes this the active offsite destination.'} Manage backups in <Link className="conn-modal__link" to="/backups">Backups →</Link>
</p>
<form className="conn-form" onSubmit={save}>
<div className="conn-form__heading"><KeyRound size={15} /> {provider.name} credentials</div>
<div className="conn-form__grid">
{fields.map((f) => (
<div key={f.k} className={`form-group${f.wide ? ' conn-form__wide' : ''}`}>
<Label htmlFor={`st-${f.k}`}>{f.label}</Label>
<Input
id={`st-${f.k}`}
type={f.secret ? 'password' : 'text'}
value={form[f.k]}
onChange={(e) => setForm((s) => ({ ...s, [f.k]: e.target.value }))}
placeholder={f.placeholder || ''}
autoComplete="off"
/>
</div>
))}
</div>
{provider.docUrl && (
<a className="conn-form__doc" href={provider.docUrl} target="_blank" rel="noreferrer">
<ExternalLink size={13} /> Where do I get this?
</a>
)}
<div className="conn-form__actions conn-form__actions--split">
<Button type="button" variant="outline" size="sm" disabled={busy || !form.bucket} onClick={test}>Test</Button>
<Button type="submit" size="sm" disabled={busy || !form.bucket}>{busy ? 'Saving…' : 'Save'}</Button>
</div>
</form>
</>
);
}
// ── Registrar: GoDaddy (domain portfolio + expiry) ──
function RegistrarBody({ provider, isAdmin, connections, onAdd, onRemove, onTest }) {
const [form, setForm] = useState({ name: provider.name, api_key: '', api_secret: '' });
const [busy, setBusy] = useState(false);
const canAdd = form.api_key.trim() && form.api_secret.trim();
async function withBusy(fn) { setBusy(true); try { return await fn(); } finally { setBusy(false); } }
async function add(e) {
e.preventDefault();
const ok = await withBusy(() => onAdd({ provider: provider.provider, name: form.name.trim() || provider.name, api_key: form.api_key.trim(), api_secret: form.api_secret.trim() }));
if (ok) setForm({ name: provider.name, api_key: '', api_secret: '' });
}
return (
<>
{connections.length > 0 && (
<div className="conn-list">
{connections.map((c) => (
<div key={c.id} className="conn-list__row">
<div className="conn-list__info">
<strong>{c.name}</strong>
<span className="conn-list__key">{c.account_label || 'Connected'}</span>
</div>
{isAdmin && (
<div className="conn-list__actions">
<Button type="button" size="sm" variant="outline" disabled={busy} onClick={() => withBusy(() => onTest(c.id))}>Test</Button>
<Button type="button" size="sm" variant="ghost" disabled={busy} onClick={() => withBusy(() => onRemove(c.id))} aria-label={`Disconnect ${c.name}`}><Trash2 size={15} /></Button>
</div>
)}
</div>
))}
</div>
)}
<p className="conn-modal__note">
<Globe size={15} /> See every domain and its expiry on the <Link className="conn-modal__link" to="/domains">Domains →</Link> page.
</p>
{!isAdmin ? (
<p className="conn-modal__note"><ShieldCheck size={15} /> Only administrators can connect a registrar.</p>
) : (
<form className="conn-form" onSubmit={add}>
<div className="conn-form__heading">{connections.length > 0 ? 'Add another account' : `Connect ${provider.name}`}</div>
<div className="conn-form__grid">
<div className="form-group conn-form__wide">
<Label htmlFor="reg-name">Account name</Label>
<Input id="reg-name" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} placeholder={provider.name} />
</div>
<div className="form-group">
<Label htmlFor="reg-key">API key</Label>
<Input id="reg-key" value={form.api_key} onChange={(e) => setForm((f) => ({ ...f, api_key: e.target.value }))} placeholder="GoDaddy API key (Production)" autoComplete="off" />
</div>
<div className="form-group">
<Label htmlFor="reg-secret">API secret</Label>
<Input id="reg-secret" type="password" value={form.api_secret} onChange={(e) => setForm((f) => ({ ...f, api_secret: e.target.value }))} placeholder="GoDaddy API secret" autoComplete="off" />
</div>
</div>
{provider.docUrl && (
<a className="conn-form__doc" href={provider.docUrl} target="_blank" rel="noreferrer">
<ExternalLink size={13} /> Where do I get this?
</a>
)}
<div className="conn-form__actions">
<Button type="submit" size="sm" disabled={busy || !canAdd}>{busy ? 'Connecting…' : 'Connect'}</Button>
</div>
</form>
)}
</>
);
}
// ── Email: outbound SMTP relay (smarthost) ──
const SMTP_PRESETS = [
{ id: 'postmark', name: 'Postmark', host: 'smtp.postmarkapp.com', port: 587 },
{ id: 'ses', name: 'Amazon SES', host: 'email-smtp.us-east-1.amazonaws.com', port: 587 },
{ id: 'mailgun', name: 'Mailgun', host: 'smtp.mailgun.org', port: 587 },
{ id: 'sendgrid', name: 'SendGrid', host: 'smtp.sendgrid.net', port: 587 },
{ id: 'custom', name: 'Custom', host: '', port: 587 },
];
function EmailBody({ provider, isAdmin, relayConfig, onSave, onTest, onDisable }) {
const cfg = relayConfig || {};
const [form, setForm] = useState({
provider_hint: cfg.provider_hint || 'custom',
host: cfg.host || '',
port: cfg.port || 587,
username: cfg.username || '',
password: '',
use_tls: cfg.use_tls !== false,
enabled: cfg.enabled !== false,
});
const [busy, setBusy] = useState(false);
const configured = Boolean(cfg.host);
function applyPreset(id) {
const p = SMTP_PRESETS.find((x) => x.id === id);
setForm((f) => ({ ...f, provider_hint: id, ...(id === 'custom' || !p ? {} : { host: p.host, port: p.port }) }));
}
function payload() {
return {
provider_hint: form.provider_hint,
host: form.host.trim(),
port: Number(form.port) || 587,
username: form.username.trim(),
...(form.password ? { password: form.password } : {}),
use_tls: form.use_tls,
enabled: form.enabled,
};
}
async function withBusy(fn) { setBusy(true); try { return await fn(); } finally { setBusy(false); } }
async function save(e) { e.preventDefault(); await withBusy(() => onSave(payload())); }
async function test() { await withBusy(() => onTest(payload())); }
if (!isAdmin) {
return <p className="conn-modal__note"><ShieldCheck size={15} /> Only administrators can configure the mail relay.</p>;
}
return (
<form className="conn-form" onSubmit={save}>
<div className="conn-form__heading"><Mail size={15} /> Outbound relay</div>
<div className="conn-presets" role="radiogroup" aria-label="Provider preset">
{SMTP_PRESETS.map((p) => (
<button
type="button"
key={p.id}
className={`conn-presets__opt${form.provider_hint === p.id ? ' is-active' : ''}`}
onClick={() => applyPreset(p.id)}
role="radio"
aria-checked={form.provider_hint === p.id}
>
{p.name}
</button>
))}
</div>
<div className="conn-form__grid">
<div className="form-group conn-form__wide">
<Label htmlFor="relay-host">SMTP host</Label>
<Input id="relay-host" value={form.host} onChange={(e) => setForm((f) => ({ ...f, host: e.target.value }))} placeholder="smtp.provider.com" autoComplete="off" />
</div>
<div className="form-group">
<Label htmlFor="relay-port">Port</Label>
<Input id="relay-port" type="number" value={form.port} onChange={(e) => setForm((f) => ({ ...f, port: e.target.value }))} placeholder="587" />
</div>
<div className="form-group">
<Label htmlFor="relay-user">Username</Label>
<Input id="relay-user" value={form.username} onChange={(e) => setForm((f) => ({ ...f, username: e.target.value }))} placeholder="SMTP username / API token" autoComplete="off" />
</div>
<div className="form-group conn-form__wide">
<Label htmlFor="relay-pass">Password{cfg.password_set ? ' (leave blank to keep)' : ''}</Label>
<Input id="relay-pass" type="password" value={form.password} onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))} placeholder={cfg.password_set ? '•••••••• stored' : 'SMTP password / API token'} autoComplete="off" />
</div>
</div>
<label className="conn-check">
<Checkbox checked={form.use_tls} onCheckedChange={(v) => setForm((f) => ({ ...f, use_tls: Boolean(v) }))} />
<span>Use STARTTLS (recommended)</span>
</label>
<label className="conn-check">
<Checkbox checked={form.enabled} onCheckedChange={(v) => setForm((f) => ({ ...f, enabled: Boolean(v) }))} />
<span>Route the mail server&apos;s outbound mail through this relay</span>
</label>
<div className="conn-form__actions conn-form__actions--split">
<span>
{configured && (
<Button type="button" variant="ghost" size="sm" disabled={busy} onClick={() => withBusy(() => onDisable())}>
<Trash2 size={15} /> Disable
</Button>
)}
</span>
<span className="conn-form__actions-group">
<Button type="button" variant="outline" size="sm" disabled={busy || !form.host.trim()} onClick={test}>Test</Button>
<Button type="submit" size="sm" disabled={busy || !form.host.trim()}>{busy ? 'Saving…' : 'Save'}</Button>
</span>
</div>
</form>
);
}
@@ -1,14 +1,15 @@
// Connections hub — the Settings → Connections tab. Presents every external
// integration ServerKit can bridge to, grouped by category, over a single
// surface. It composes two backends that already exist instead of inventing a
// new one:
// - GitHub → /source-connections (per-user OAuth)
// - Cloudflare,
// Route 53 → /email/dns-providers (admin API-key configs)
// Each connected provider shows its access level ("scope") so you can see at a
// glance whether ServerKit holds a least-privilege token or a full-account key.
// surface. It composes the backends that already exist instead of inventing new
// ones, keyed by each provider's `kind` (see providerCatalog.js):
// - source → /source-connections (GitHub, GitLab OAuth)
// - cloud → /cloud (DigitalOcean, Hetzner, Vultr, Linode)
// - dns → /email/dns-providers (Cloudflare, Route 53, DigitalOcean, GoDaddy)
// - registrar → /registrars (GoDaddy domain portfolio + expiry)
// - storage → /backups/storage (S3-compatible, Backblaze B2)
// Each connected provider shows its status, access scope, and a cross-link to the
// in-app page it powers (Servers, Domains, Backups, New Service).
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link2 } from 'lucide-react';
import api from '../../../services/api';
import { useAuth } from '../../../contexts/AuthContext';
import { useToast } from '../../../contexts/ToastContext';
@@ -22,24 +23,46 @@ export default function ConnectionsHub() {
const { isAdmin } = useAuth();
const toast = useToast();
const [githubStatus, setGithubStatus] = useState(null);
const [githubConfig, setGithubConfig] = useState(null);
const [sourceStatus, setSourceStatus] = useState({ github: null, gitlab: null });
const [sourceConfig, setSourceConfig] = useState({ github: null, gitlab: null });
const [dnsProviders, setDnsProviders] = useState([]);
const [cloudProviders, setCloudProviders] = useState([]);
const [storageConfig, setStorageConfig] = useState(null);
const [relayConfig, setRelayConfig] = useState(null);
const [registrarConnections, setRegistrarConnections] = useState([]);
const [registrarDomains, setRegistrarDomains] = useState([]);
const [loading, setLoading] = useState(true);
const [modalProvider, setModalProvider] = useState(null);
const [modalOpen, setModalOpen] = useState(false);
const loadData = useCallback(async () => {
try {
const [status, dns] = await Promise.all([
const [ghStatus, glStatus, dns, cloudP, storage, relay, regConns, regDomains] = await Promise.all([
api.getGithubSourceStatus().catch(() => null),
api.getGitlabSourceStatus().catch(() => null),
api.getEmailDNSProviders().then((d) => d.providers || []).catch(() => []),
api.getCloudProviders().then((d) => d.providers || []).catch(() => []),
api.getStorageConfig().catch(() => null),
api.getEmailRelay().catch(() => null),
api.getRegistrarConnections().then((d) => d.connections || []).catch(() => []),
api.getRegistrarDomains().then((d) => d.domains || []).catch(() => []),
]);
setGithubStatus(status);
setSourceStatus({ github: ghStatus, gitlab: glStatus });
setDnsProviders(dns);
setCloudProviders(cloudP);
setStorageConfig(storage);
setRelayConfig(relay);
setRegistrarConnections(regConns);
setRegistrarDomains(regDomains);
if (isAdmin) {
const cfg = await api.getGithubSourceConfig().catch(() => null);
setGithubConfig(cfg?.config || { client_id: '', client_secret: '' });
const [ghCfg, glCfg] = await Promise.all([
api.getGithubSourceConfig().catch(() => null),
api.getGitlabSourceConfig().catch(() => null),
]);
setSourceConfig({
github: ghCfg?.config || { client_id: '', client_secret: '' },
gitlab: glCfg?.config || { client_id: '', client_secret: '' },
});
}
} finally {
setLoading(false);
@@ -48,75 +71,68 @@ export default function ConnectionsHub() {
useEffect(() => { loadData(); }, [loadData]);
const refreshDns = useCallback(async () => {
const d = await api.getEmailDNSProviders().then((r) => r.providers || []).catch(() => []);
setDnsProviders(d);
}, []);
// ── Handlers (return truthy on success so the modal can reset its form) ──
const onConnectGithub = useCallback(async () => {
// ── Source (GitHub / GitLab) ──
const onConnectSource = useCallback(async (provider) => {
try {
const redirectUri = `${window.location.origin}/connections/callback/github`;
const redirectUri = `${window.location.origin}/connections/callback/${provider.provider}`;
sessionStorage.setItem('sourceConnectionReturnTo', '/settings/connections');
const { auth_url } = await api.startSourceConnection('github', redirectUri);
const { auth_url } = await api.startSourceConnection(provider.provider, redirectUri);
window.location.href = auth_url;
} catch (err) {
toast.error(err.message || 'Failed to start GitHub connection');
toast.error(err.message || `Failed to start ${provider.name} connection`);
}
}, [toast]);
const onDisconnectGithub = useCallback(async () => {
const onDisconnectSource = useCallback(async (provider) => {
try {
await api.disconnectSourceConnection('github');
toast.success('GitHub disconnected');
const status = await api.getGithubSourceStatus().catch(() => null);
setGithubStatus(status);
await api.disconnectSourceConnection(provider.provider);
toast.success(`${provider.name} disconnected`);
await loadData();
setModalOpen(false);
} catch (err) {
toast.error(err.message || 'Failed to disconnect GitHub');
toast.error(err.message || 'Failed to disconnect');
}
}, [toast]);
}, [toast, loadData]);
const onSaveGithubConfig = useCallback(async (config) => {
const onSaveSourceConfig = useCallback(async (provider, config) => {
try {
const result = await api.updateGithubSourceConfig(config);
setGithubConfig(result.config || config);
const status = await api.getGithubSourceStatus().catch(() => null);
setGithubStatus(status);
toast.success('GitHub OAuth app saved');
if (provider.provider === 'gitlab') await api.updateGitlabSourceConfig(config);
else await api.updateGithubSourceConfig(config);
toast.success(`${provider.name} OAuth app saved`);
await loadData();
return true;
} catch (err) {
toast.error(err.message || 'Failed to save GitHub OAuth app');
toast.error(err.message || 'Failed to save OAuth app');
return false;
}
}, [toast]);
}, [toast, loadData]);
// ── DNS ──
const onAddDns = useCallback(async (payload) => {
try {
const res = await api.addEmailDNSProvider(payload);
if (res && res.success === false) throw new Error(res.error || 'Failed to add connection');
toast.success(`${payload.name} connected`);
await refreshDns();
await loadData();
return true;
} catch (err) {
toast.error(err.message || 'Failed to add connection');
return false;
}
}, [toast, refreshDns]);
}, [toast, loadData]);
const onRemoveDns = useCallback(async (record) => {
if (!window.confirm(`Remove the connection "${record.name}"?`)) return false;
try {
await api.deleteEmailDNSProvider(record.id);
toast.success(`${record.name} removed`);
await refreshDns();
await loadData();
return true;
} catch (err) {
toast.error(err.message || 'Failed to remove connection');
return false;
}
}, [toast, refreshDns]);
}, [toast, loadData]);
const onTestDns = useCallback(async (id) => {
try {
@@ -130,52 +146,217 @@ export default function ConnectionsHub() {
}
}, [toast]);
// ── Per-provider summaries for the cards ──
// ── Cloud (server provisioning) ──
const onAddCloud = useCallback(async (payload) => {
try {
await api.createCloudProvider(payload); // { provider_type, name, api_key }
toast.success(`${payload.name || 'Provider'} connected`);
await loadData();
return true;
} catch (err) {
toast.error(err.message || 'Failed to connect provider');
return false;
}
}, [toast, loadData]);
const onRemoveCloud = useCallback(async (id) => {
if (!window.confirm('Disconnect this cloud account? Existing servers are not affected.')) return false;
try {
await api.deleteCloudProvider(id);
toast.success('Disconnected');
await loadData();
return true;
} catch (err) {
toast.error(err.message || 'Failed to disconnect');
return false;
}
}, [toast, loadData]);
// ── Storage ──
const onSaveStorage = useCallback(async (config) => {
try {
const res = await api.updateStorageConfig(config);
if (res && res.success === false) throw new Error(res.error || 'Failed to save storage');
toast.success('Storage saved');
await loadData();
return true;
} catch (err) {
toast.error(err.message || 'Failed to save storage');
return false;
}
}, [toast, loadData]);
const onTestStorage = useCallback(async (config) => {
try {
const res = await api.testStorageConnection(config);
if (res && res.success) toast.success(res.message || 'Connection works');
else toast.error((res && res.error) || 'Connection test failed');
return res;
} catch (err) {
toast.error(err.message || 'Connection test failed');
return null;
}
}, [toast]);
// ── Email relay ──
const onSaveRelay = useCallback(async (payload) => {
try {
const res = await api.updateEmailRelay(payload);
toast.success(res?.apply?.note || 'Relay saved');
await loadData();
return true;
} catch (err) {
toast.error(err.message || 'Failed to save relay');
return false;
}
}, [toast, loadData]);
const onTestRelay = useCallback(async (payload) => {
try {
const res = await api.testEmailRelay(payload);
if (res && res.success) toast.success(res.message || 'Connection works');
else toast.error((res && res.error) || 'Connection test failed');
return res;
} catch (err) {
toast.error(err.message || 'Connection test failed');
return null;
}
}, [toast]);
const onDisableRelay = useCallback(async () => {
try {
await api.disableEmailRelay();
toast.success('Relay disabled');
await loadData();
return true;
} catch (err) {
toast.error(err.message || 'Failed to disable relay');
return false;
}
}, [toast, loadData]);
// ── Registrar ──
const onAddRegistrar = useCallback(async (payload) => {
try {
await api.addRegistrarConnection(payload);
toast.success(`${payload.name || 'Registrar'} connected`);
await loadData();
return true;
} catch (err) {
toast.error(err.message || 'Failed to connect registrar');
return false;
}
}, [toast, loadData]);
const onRemoveRegistrar = useCallback(async (id) => {
if (!window.confirm('Disconnect this registrar?')) return false;
try {
await api.deleteRegistrarConnection(id);
toast.success('Registrar disconnected');
await loadData();
return true;
} catch (err) {
toast.error(err.message || 'Failed to disconnect');
return false;
}
}, [toast, loadData]);
const onTestRegistrar = useCallback(async (id) => {
try {
const res = await api.testRegistrarConnection(id);
if (res && res.success) toast.success(res.message || 'Connection works');
else toast.error((res && res.error) || 'Connection test failed');
return res;
} catch (err) {
toast.error(err.message || 'Connection test failed');
return null;
}
}, [toast]);
// ── Per-provider card summaries ──
const summaries = useMemo(() => {
const dnsSummary = (providerKey) => {
const list = dnsProviders.filter((p) => p.provider === providerKey);
if (!list.length) return { connected: false, statusLabel: 'Not connected', statusTone: 'neutral', scopes: [] };
return {
connected: true,
statusLabel: list.length === 1 ? 'Connected' : `${list.length} connected`,
statusTone: 'ok',
subtitle: list.map((p) => p.name).join(', '),
scopes: dedupeScopes(list.map(deriveScope).filter(Boolean)),
};
};
const out = {};
const cloudByType = (type) => cloudProviders.filter((p) => p.provider_type === type);
const ghConn = githubStatus?.connection;
return {
github: ghConn
? {
connected: true, statusLabel: 'Connected', statusTone: 'ok',
subtitle: ghConn.provider_username ? `@${ghConn.provider_username}` : (ghConn.display_name || null),
scopes: [{ label: 'OAuth', tone: 'neutral', hint: ghConn.scope || 'Authorized via OAuth' }],
}
: { connected: false, statusLabel: githubStatus?.configured ? 'Not connected' : 'Setup needed', statusTone: 'neutral', scopes: [] },
cloudflare: dnsSummary('cloudflare'),
route53: dnsSummary('route53'),
};
}, [githubStatus, dnsProviders]);
for (const provider of CONNECTION_PROVIDERS) {
if (provider.comingSoon) { out[provider.id] = { connected: false }; continue; }
const manageHref = provider.manageHref;
if (provider.kind === 'source') {
const status = sourceStatus[provider.provider];
const conn = status?.connection;
out[provider.id] = conn
? {
connected: true, statusLabel: 'Connected', statusTone: 'ok',
subtitle: conn.provider_username ? `@${conn.provider_username}` : (conn.display_name || null),
scopes: [{ label: 'OAuth', tone: 'neutral', hint: conn.scope || 'Authorized via OAuth' }],
manageHref, manageLabel: 'New service',
}
: { connected: false, statusLabel: status?.configured ? 'Not connected' : 'Setup needed', statusTone: 'neutral', scopes: [] };
} else if (provider.kind === 'cloud') {
const matches = cloudByType(provider.providerType);
const count = matches.reduce((n, p) => n + (p.server_count || 0), 0);
out[provider.id] = matches.length
? {
connected: true, statusLabel: 'Connected', statusTone: 'ok',
subtitle: count ? `${count} server${count === 1 ? '' : 's'}` : 'No servers yet',
scopes: [], manageHref, manageLabel: 'Servers',
}
: { connected: false, statusLabel: 'Not connected', statusTone: 'neutral', scopes: [] };
} else if (provider.kind === 'dns') {
const list = dnsProviders.filter((p) => p.provider === provider.provider);
out[provider.id] = list.length
? {
connected: true, statusLabel: list.length === 1 ? 'Connected' : `${list.length} connected`, statusTone: 'ok',
subtitle: list.map((p) => p.name).join(', '),
scopes: dedupeScopes(list.map(deriveScope).filter(Boolean)),
}
: { connected: false, statusLabel: 'Not connected', statusTone: 'neutral', scopes: [] };
} else if (provider.kind === 'registrar') {
const list = registrarConnections.filter((c) => c.provider === provider.provider);
const mine = registrarDomains.filter((d) => d.registrar === provider.provider);
const expiring = mine.filter((d) => d.days_until_expiry != null && d.days_until_expiry <= 30).length;
out[provider.id] = list.length
? {
connected: true, statusLabel: 'Connected', statusTone: 'ok',
subtitle: `${mine.length} domain${mine.length === 1 ? '' : 's'}${expiring ? ` · ${expiring} expiring ≤30d` : ''}`,
scopes: expiring ? [{ label: `${expiring} expiring`, tone: 'warn', hint: 'Registration expires within 30 days' }] : [],
manageHref, manageLabel: 'Domains',
}
: { connected: false, statusLabel: 'Not connected', statusTone: 'neutral', scopes: [] };
} else if (provider.kind === 'storage') {
const active = storageConfig?.provider === provider.storageProvider;
const sub = storageConfig?.[provider.storageProvider];
out[provider.id] = active && sub?.bucket
? {
connected: true, statusLabel: 'Active', statusTone: 'ok',
subtitle: `Bucket: ${sub.bucket}`,
scopes: [{ label: 'Backups', tone: 'neutral', hint: 'Used as the offsite backup destination' }],
manageHref, manageLabel: 'Backups',
}
: { connected: false, statusLabel: 'Not connected', statusTone: 'neutral', scopes: [] };
} else if (provider.kind === 'email') {
out[provider.id] = (relayConfig?.enabled && relayConfig?.host)
? {
connected: true, statusLabel: 'Active', statusTone: 'ok',
subtitle: `${relayConfig.host}:${relayConfig.port || 587}`,
scopes: relayConfig.use_tls ? [{ label: 'TLS', tone: 'ok', hint: 'STARTTLS enabled' }] : [],
}
: { connected: false, statusLabel: 'Not connected', statusTone: 'neutral', scopes: [] };
} else {
out[provider.id] = { connected: false };
}
}
return out;
}, [sourceStatus, cloudProviders, dnsProviders, registrarConnections, registrarDomains, storageConfig, relayConfig]);
function handleManage(provider) {
setModalProvider(provider);
setModalOpen(true);
}
const connectedCount = (summaries.github.connected ? 1 : 0) + dnsProviders.length;
return (
<div className="connections-hub">
<div className="connections-hub__intro">
<span className="connections-hub__intro-icon"><Link2 size={20} /></span>
<div className="connections-hub__intro-text">
<h2>Connections</h2>
<p>Bridge ServerKit to the services you already use — source hosts, DNS providers, mail and storage. {connectedCount > 0 ? `${connectedCount} active.` : 'Nothing connected yet.'}</p>
</div>
</div>
{loading ? (
<div className="connections-hub__loading">Loading connections…</div>
) : (
@@ -207,16 +388,30 @@ export default function ConnectionsHub() {
provider={modalProvider}
open={modalOpen}
onOpenChange={setModalOpen}
githubStatus={githubStatus}
githubConfig={githubConfig}
dnsProviders={dnsProviders}
isAdmin={isAdmin}
onConnectGithub={onConnectGithub}
onDisconnectGithub={onDisconnectGithub}
onSaveGithubConfig={onSaveGithubConfig}
sourceStatus={sourceStatus}
sourceConfig={sourceConfig}
dnsProviders={dnsProviders}
cloudProviders={cloudProviders}
storageConfig={storageConfig}
registrarConnections={registrarConnections}
onConnectSource={onConnectSource}
onDisconnectSource={onDisconnectSource}
onSaveSourceConfig={onSaveSourceConfig}
onAddDns={onAddDns}
onRemoveDns={onRemoveDns}
onTestDns={onTestDns}
onAddCloud={onAddCloud}
onRemoveCloud={onRemoveCloud}
onSaveStorage={onSaveStorage}
onTestStorage={onTestStorage}
onAddRegistrar={onAddRegistrar}
onRemoveRegistrar={onRemoveRegistrar}
onTestRegistrar={onTestRegistrar}
relayConfig={relayConfig}
onSaveRelay={onSaveRelay}
onTestRelay={onTestRelay}
onDisableRelay={onDisableRelay}
/>
</div>
);
@@ -1,8 +1,10 @@
// One provider tile in the Connections hub. Presentational only — the hub
// computes a `summary` ({ connected, statusLabel, statusTone, subtitle, scopes })
// and the card just renders it. "Coming soon" providers render dimmed with no
// action so the catalog reads as complete without overpromising.
import { ArrowRight } from 'lucide-react';
// computes a `summary` ({ connected, statusLabel, statusTone, subtitle, scopes,
// manageHref, manageLabel }) and the card just renders it. "Coming soon"
// providers render dimmed with no action so the catalog reads as complete
// without overpromising.
import { ArrowRight, ArrowUpRight } from 'lucide-react';
import { Link } from 'react-router-dom';
import { ProviderBrandIcon } from '../../icons/ProviderBrands';
import { Button } from '@/components/ui/button';
@@ -40,6 +42,12 @@ export default function ProviderCard({ provider, summary, onManage }) {
)}
<div className="conn-card__actions">
{connected && summary.manageHref ? (
<Link className="conn-card__crosslink" to={summary.manageHref}>
{summary.manageLabel || 'Open'} <ArrowUpRight size={14} />
</Link>
) : <span />}
{comingSoon ? (
<span className="conn-card__soon-tag">Coming soon</span>
) : (
@@ -1,30 +1,61 @@
// The Connections catalog — the declarative source of truth for the integrations
// hub. Each provider belongs to a category and is either live (wired to a real
// backend) or `comingSoon` (shown so the hub reads as a complete surface rather
// than a one-provider page). Live providers map to one of two backends that
// already exist:
// - kind 'github' → /source-connections (per-user OAuth)
// - kind 'dns' → /email/dns-providers (admin API-key configs: Cloudflare, Route 53)
//
// Keeping this as plain data (no JSX) lets ConnectionsHub render every category
// generically and makes adding a provider a one-line change.
// hub. Each provider belongs to a category and maps to a backend by its `kind`:
// - kind 'source' → /source-connections (per-user OAuth: GitHub, GitLab)
// - kind 'cloud' → /cloud (server provisioning: DO, Hetzner, Vultr, Linode)
// - kind 'dns' → /email/dns-providers (records + wildcard TLS: Cloudflare, Route 53, DO, GoDaddy)
// - kind 'registrar' → /registrars (domain ownership + expiry: GoDaddy)
// - kind 'storage' → /backups/storage (offsite backups: S3-compatible, Backblaze B2)
// `comingSoon: true` renders a dimmed, actionless tile so the catalog reads as a
// complete surface. `manageHref` is the in-app page a live connection powers,
// surfaced as a quiet cross-link on the card.
export const CONNECTION_CATEGORIES = [
{ key: 'source', label: 'Source code', blurb: 'Create services straight from a repository instead of pasting clone URLs.' },
{ key: 'infra', label: 'Infrastructure', blurb: 'Cloud accounts ServerKit can provision and manage servers in.' },
{ key: 'dns', label: 'DNS & domains', blurb: 'Let ServerKit manage DNS records and issue wildcard certificates automatically.' },
{ key: 'registrar', label: 'Registrars & ownership', blurb: 'Track the domains you own and when their registration expires.' },
{ key: 'email', label: 'Email & delivery', blurb: 'Outbound relays and deliverability for the mail server.' },
{ key: 'storage', label: 'Storage & backups', blurb: 'Off-site destinations for backups and large assets.' },
];
export const CONNECTION_PROVIDERS = [
// ── Source code ──
{
id: 'github', category: 'source', name: 'GitHub', kind: 'github',
id: 'github', category: 'source', name: 'GitHub', kind: 'source', provider: 'github',
blurb: 'List repositories over the GitHub API and import selected branches.',
docUrl: 'https://github.com/settings/developers',
docUrl: 'https://github.com/settings/developers', manageHref: '/services/new',
},
{
id: 'cloudflare', category: 'dns', name: 'Cloudflare', kind: 'dns', provider: 'cloudflare',
supportsScope: true,
id: 'gitlab', category: 'source', name: 'GitLab', kind: 'source', provider: 'gitlab',
blurb: 'Cloud GitLab repositories, imported the same way as GitHub.',
docUrl: 'https://gitlab.com/-/profile/applications', manageHref: '/services/new',
},
// ── Infrastructure (cloud servers) ──
{
id: 'digitalocean', category: 'infra', name: 'DigitalOcean', kind: 'cloud', providerType: 'digitalocean',
blurb: 'Provision and manage droplets as servers in your fleet.',
docUrl: 'https://cloud.digitalocean.com/account/api/tokens', manageHref: '/servers',
},
{
id: 'hetzner', category: 'infra', name: 'Hetzner Cloud', kind: 'cloud', providerType: 'hetzner',
blurb: 'Provision and manage Hetzner Cloud servers.',
docUrl: 'https://console.hetzner.cloud/', manageHref: '/servers',
},
{
id: 'vultr', category: 'infra', name: 'Vultr', kind: 'cloud', providerType: 'vultr',
blurb: 'Provision and manage Vultr instances.',
docUrl: 'https://my.vultr.com/settings/#settingsapi', manageHref: '/servers',
},
{
id: 'linode', category: 'infra', name: 'Linode', kind: 'cloud', providerType: 'linode',
blurb: 'Provision and manage Linodes (Akamai).',
docUrl: 'https://cloud.linode.com/profile/tokens', manageHref: '/servers',
},
// ── DNS & domains ──
{
id: 'cloudflare', category: 'dns', name: 'Cloudflare', kind: 'dns', provider: 'cloudflare', supportsScope: true,
blurb: 'Auto-create DNS records and wildcard TLS for the domains you manage.',
docUrl: 'https://dash.cloudflare.com/profile/api-tokens',
},
@@ -33,21 +64,53 @@ export const CONNECTION_PROVIDERS = [
blurb: 'Manage records in AWS hosted zones with an access-key pair.',
docUrl: 'https://console.aws.amazon.com/iam/home#/security_credentials',
},
{ id: 'gitlab', category: 'source', name: 'GitLab', comingSoon: true, blurb: 'Cloud or self-managed GitLab repositories.' },
{ id: 'digitalocean', category: 'dns', name: 'DigitalOcean DNS', comingSoon: true, blurb: 'Manage records in DigitalOcean-hosted domains.' },
{ id: 'smtp', category: 'email', name: 'SMTP relay', comingSoon: true, blurb: 'Send outbound mail through a provider like Postmark or SES.' },
{ id: 's3', category: 'storage', name: 'S3 / object storage', comingSoon: true, blurb: 'Stream backups to any S3-compatible bucket.' },
{
id: 'digitalocean_dns', category: 'dns', name: 'DigitalOcean DNS', kind: 'dns', provider: 'digitalocean',
blurb: 'Manage records in DigitalOcean-hosted domains with an API token.',
docUrl: 'https://cloud.digitalocean.com/account/api/tokens',
},
{
id: 'godaddy_dns', category: 'dns', name: 'GoDaddy DNS', kind: 'dns', provider: 'godaddy',
blurb: 'Manage DNS records in GoDaddy-hosted domains.',
docUrl: 'https://developer.godaddy.com/keys',
},
// ── Registrars & ownership ──
{
id: 'godaddy', category: 'registrar', name: 'GoDaddy', kind: 'registrar', provider: 'godaddy',
blurb: 'Track domains you own at GoDaddy and when they expire.',
docUrl: 'https://developer.godaddy.com/keys', manageHref: '/domains',
},
{ id: 'namecheap', category: 'registrar', name: 'Namecheap', comingSoon: true, blurb: 'Domain ownership and expiry from Namecheap.' },
// ── Email & delivery ──
{
id: 'smtp', category: 'email', name: 'SMTP relay', kind: 'email',
blurb: 'Send outbound mail through a provider like Postmark, SES or Mailgun.',
},
// ── Storage & backups ──
{
id: 's3', category: 'storage', name: 'S3 / object storage', kind: 'storage', storageProvider: 's3',
blurb: 'Stream backups to any S3-compatible bucket (AWS, Wasabi, MinIO, Spaces).',
docUrl: 'https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html', manageHref: '/backups',
},
{
id: 'b2', category: 'storage', name: 'Backblaze B2', kind: 'storage', storageProvider: 'b2',
blurb: 'Stream backups to a Backblaze B2 bucket via its S3 endpoint.',
docUrl: 'https://www.backblaze.com/docs/cloud-storage-application-keys', manageHref: '/backups',
},
];
export function getProvider(id) {
return CONNECTION_PROVIDERS.find((p) => p.id === id) || null;
}
// Access-level ("scope") derivation — the heart of the connect experience. A
// Cloudflare config authenticated with an account email uses a Global API Key
// (full account); without one it's a least-privilege scoped token. Route 53
// access keys derive their scope from the attached IAM policy, which we can't
// introspect, so we label them neutrally. Returns { label, tone, hint } or null.
// Access-level ("scope") derivation for DNS-provider records. Cloudflare with an
// account email uses a Global API Key (full account); without one it's a scoped
// token. Route 53 / GoDaddy keys derive scope from IAM / account policy we can't
// introspect, so they're labeled neutrally. DigitalOcean uses a single token.
// Returns { label, tone, hint } or null.
export function deriveScope(record) {
if (!record) return null;
if (record.provider === 'cloudflare') {
@@ -58,6 +121,12 @@ export function deriveScope(record) {
if (record.provider === 'route53') {
return { label: 'Access key', tone: 'neutral', hint: 'Scope set by IAM policy' };
}
if (record.provider === 'digitalocean') {
return { label: 'API token', tone: 'ok', hint: 'DigitalOcean personal access token' };
}
if (record.provider === 'godaddy') {
return { label: 'API key', tone: 'neutral', hint: 'GoDaddy account API key' };
}
return null;
}
+3
View File
@@ -16,6 +16,7 @@ import {
} from '@/components/ui/select';
import { PageTopbar, MetricCard, SegControl, Pill, Drawer } from '@/components/ds';
import { DOMAIN_TABS } from '../components/domains/domainTabs';
import RegistrarPortfolio from '../components/domains/RegistrarPortfolio';
const Domains = () => {
const toast = useToast();
@@ -242,6 +243,8 @@ const Domains = () => {
)}
/>
<RegistrarPortfolio />
{error && (
<div className="error-banner">
{error}
+26
View File
@@ -0,0 +1,26 @@
// Domain-registrar connections — the portfolio + expiry surface that powers the
// Connections → Registrars cards and the Domains-page portfolio.
export async function getRegistrarConnections() {
return this.request('/registrars/connections');
}
export async function addRegistrarConnection(data) {
return this.request('/registrars/connections', { method: 'POST', body: data });
}
export async function deleteRegistrarConnection(id) {
return this.request(`/registrars/connections/${id}`, { method: 'DELETE' });
}
export async function testRegistrarConnection(id) {
return this.request(`/registrars/connections/${id}/test`, { method: 'POST' });
}
export async function getRegistrarDomains() {
return this.request('/registrars/domains');
}
export async function syncRegistrarDomains() {
return this.request('/registrars/sync', { method: 'POST' });
}
+2
View File
@@ -13,6 +13,7 @@ import * as pluginMethods from './plugins.js';
import * as deploymentJobMethods from './deploymentJobs.js';
import * as pairingMethods from './pairing.js';
import * as sourceConnectionMethods from './sourceConnections.js';
import * as connectionMethods from './connections.js';
import * as aiMethods from './ai.js';
class ApiService extends ApiClient {
@@ -34,6 +35,7 @@ class ApiService extends ApiClient {
deploymentJobMethods,
pairingMethods,
sourceConnectionMethods,
connectionMethods,
aiMethods,
];
for (const mod of modules) {
@@ -48,3 +48,21 @@ export async function updateGithubSourceConfig(config) {
body: config,
});
}
// GitLab mirrors the GitHub surface (same generic connect/disconnect helpers
// above, which already take a `provider` argument).
export async function getGitlabSourceStatus() {
return this.request('/source-connections/gitlab/status');
}
export async function getGitlabSourceConfig() {
return this.request('/source-connections/admin/gitlab');
}
export async function updateGitlabSourceConfig(config) {
return this.request('/source-connections/admin/gitlab', {
method: 'PUT',
body: config,
});
}
+6
View File
@@ -740,6 +740,12 @@ export async function deleteEmailDNSProvider(providerId) { return this.request(`
export async function testEmailDNSProvider(providerId) { return this.request(`/email/dns-providers/${providerId}/test`, { method: 'POST' }); }
export async function getEmailDNSZones(providerId) { return this.request(`/email/dns-providers/${providerId}/zones`); }
// Outbound SMTP relay (smarthost)
export async function getEmailRelay() { return this.request('/email/relay'); }
export async function updateEmailRelay(data) { return this.request('/email/relay', { method: 'PUT', body: JSON.stringify(data) }); }
export async function testEmailRelay(data) { return this.request('/email/relay/test', { method: 'POST', body: JSON.stringify(data) }); }
export async function disableEmailRelay() { return this.request('/email/relay', { method: 'DELETE' }); }
// SpamAssassin
export async function getSpamConfig() { return this.request('/email/spam/config'); }
export async function updateSpamConfig(data) { return this.request('/email/spam/config', { method: 'PUT', body: JSON.stringify(data) }); }
+229 -35
View File
@@ -11,40 +11,6 @@
flex-direction: column;
gap: $space-6;
&__intro {
display: flex;
align-items: flex-start;
gap: $space-3;
}
&__intro-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 38px;
height: 38px;
border-radius: $radius-lg;
background: var(--accent-bg);
color: var(--accent-bright);
}
&__intro-text {
h2 {
margin: 0;
font-size: $font-size-lg;
font-weight: $font-weight-semibold;
color: $text-primary;
}
p {
margin: $space-1 0 0;
font-size: $font-size-sm;
color: $text-secondary;
max-width: 70ch;
}
}
&__loading {
padding: $space-8;
text-align: center;
@@ -165,7 +131,8 @@
&__actions {
display: flex;
align-items: center;
justify-content: flex-end;
justify-content: space-between;
gap: $space-2;
margin-top: auto;
button {
@@ -175,6 +142,21 @@
}
}
&__crosslink {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: $font-size-xs;
font-weight: $font-weight-medium;
color: $text-tertiary;
text-decoration: none;
transition: color $transition-fast;
&:hover {
color: var(--accent-bright);
}
}
&__soon-tag {
font-size: $font-size-xs;
font-weight: $font-weight-medium;
@@ -272,6 +254,16 @@
color: $text-tertiary;
}
&__link {
color: var(--accent-bright);
text-decoration: none;
font-weight: $font-weight-medium;
&:hover {
text-decoration: underline;
}
}
.form-group {
display: flex;
flex-direction: column;
@@ -449,6 +441,11 @@
&__actions {
display: flex;
justify-content: flex-end;
gap: $space-2;
&--split {
justify-content: space-between;
}
}
}
@@ -505,9 +502,206 @@
}
}
// ── Preset chips (SMTP relay provider presets) ──
.conn-presets {
display: flex;
flex-wrap: wrap;
gap: $space-2;
&__opt {
padding: 5px $space-3;
background: $bg-card;
border: 1px solid $border-subtle;
border-radius: $radius-full;
font-size: $font-size-xs;
font-weight: $font-weight-medium;
color: $text-secondary;
cursor: pointer;
transition: border-color $transition-fast, background $transition-fast, color $transition-fast;
&:hover {
border-color: $border-active;
color: $text-primary;
}
&.is-active {
border-color: var(--accent-dim);
background: var(--accent-bg);
color: var(--accent-bright);
}
}
}
// ── Inline checkbox row (relay TLS / enable) ──
.conn-check {
display: flex;
align-items: center;
gap: $space-2;
font-size: $font-size-sm;
color: $text-secondary;
cursor: pointer;
}
.conn-form__actions-group {
display: flex;
align-items: center;
gap: $space-2;
}
@media (max-width: $breakpoint-sm) {
.conn-scope,
.conn-form__grid {
grid-template-columns: 1fr;
}
}
// ── Registered-domains portfolio (Domains page; fed by /registrars/domains) ──
.reg-portfolio {
margin-bottom: $space-5;
background: $bg-card;
border: 1px solid $border-subtle;
border-radius: $radius-lg;
overflow: hidden;
&__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: $space-3;
padding: $space-3 $space-4;
border-bottom: 1px solid $border-subtle;
}
&__title {
display: flex;
align-items: center;
gap: $space-2;
color: $text-secondary;
h2 {
margin: 0;
font-size: $font-size-base;
font-weight: $font-weight-semibold;
color: $text-primary;
}
}
&__count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 22px;
height: 20px;
padding: 0 6px;
border-radius: $radius-full;
background: var(--surface-2);
font-size: $font-size-xs;
font-weight: $font-weight-semibold;
color: $text-secondary;
}
&__warn {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: $font-size-xs;
font-weight: $font-weight-medium;
color: $warning;
}
&__refresh {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 5px $space-2;
background: transparent;
border: 1px solid $border-subtle;
border-radius: $radius-md;
font-size: $font-size-xs;
font-weight: $font-weight-medium;
color: $text-secondary;
cursor: pointer;
transition: border-color $transition-fast, color $transition-fast;
&:hover:not(:disabled) {
border-color: $border-active;
color: $text-primary;
}
&:disabled { opacity: 0.6; cursor: default; }
.is-spinning { animation: reg-portfolio-spin 0.8s linear infinite; }
}
&__list {
display: flex;
flex-direction: column;
}
&__row {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr) auto auto auto;
align-items: center;
gap: $space-3;
padding: $space-3 $space-4;
& + & { border-top: 1px solid $border-subtle; }
}
&__domain {
font-size: $font-size-sm;
font-weight: $font-weight-semibold;
color: $text-primary;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__via {
font-size: $font-size-xs;
color: $text-tertiary;
}
&__expiry {
font-size: $font-size-xs;
font-weight: $font-weight-semibold;
padding: 2px $space-2;
border-radius: $radius-full;
white-space: nowrap;
text-align: center;
&--ok { color: $success; background: $success-bg; }
&--warn { color: $warning; background: fade($warning, 12%); }
&--danger,
&--expired { color: $danger; background: fade($danger, 12%); }
&--neutral { color: $text-tertiary; background: var(--surface-2); }
}
&__date {
font-size: $font-size-xs;
color: $text-tertiary;
white-space: nowrap;
}
&__renew {
font-size: 11px;
font-weight: $font-weight-medium;
color: $text-tertiary;
&.is-on { color: $success; }
}
}
@keyframes reg-portfolio-spin {
to { transform: rotate(360deg); }
}
@media (max-width: $breakpoint-md) {
.reg-portfolio__row {
grid-template-columns: minmax(0, 1fr) auto;
row-gap: $space-1;
}
.reg-portfolio__via,
.reg-portfolio__date { display: none; }
}