mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-26 13:06:57 +09:00
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:
1 parent
2e1bdbcb3d
commit
c1d4653f6c
26 files changed
+2731
-336
No files matched your search
@@ -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')
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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()})
|
||||
@@ -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)
|
||||
@@ -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',
|
||||
|
||||
@@ -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}>'
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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)}
|
||||
@@ -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)}
|
||||
@@ -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)}
|
||||
@@ -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('/')
|
||||
|
||||
@@ -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'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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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) }); }
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
Reference in new issue
Block a user