Add Connections hub and provider UI

Introduce a new Connections hub for Settings: adds presentational components and catalog for external providers (ProviderBrands, ProviderCard, ConnectionsHub, ConnectProviderModal, providerCatalog) plus SCSS (_connections.scss) and brand icons. Replaces the old GitHub-only SourceConnectionsTab (removed) and wires ConnectionsHub into Settings. Also imports the new styles in main.scss and tweaks settings styles. Provides UI to list/manage GitHub/DNS providers, show scopes, and handle connect/manage flows; keeps several providers marked comingSoon for future work.
This commit is contained in:
Juan Denis committed 2026-06-15 02:22:29 -04:00
1 parent 5212704942
commit 2e1bdbcb3d
10 files changed
+1169 -196

No files matched your search

@@ -0,0 +1,31 @@
// 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.
//
// 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 { FaAws } from 'react-icons/fa';
import { Mail, HardDrive, Plug } from 'lucide-react';
// provider id (matches providerCatalog) -> brand icon component.
const PROVIDER_ICONS = {
github: SiGithub,
gitlab: SiGitlab,
cloudflare: SiCloudflare,
route53: FaAws,
digitalocean: SiDigitalocean,
smtp: Mail,
s3: HardDrive,
};
// Renders the brand icon for a provider, falling back to the generic lucide Plug
// glyph for anything we don't have a brand for.
export function ProviderBrandIcon({ provider, size = 22, className }) {
const Cmp = PROVIDER_ICONS[provider] || Plug;
return <Cmp size={size} className={className} aria-hidden="true" />;
}
export default ProviderBrandIcon;
@@ -1,194 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { CheckCircle2, Github, KeyRound, Link2, PlugZap, Shield, Trash2 } from 'lucide-react';
import api from '../../services/api';
import { useAuth } from '../../contexts/AuthContext';
import { useToast } from '../../contexts/ToastContext';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
const SourceConnectionsTab = () => {
const { isAdmin } = useAuth();
const toast = useToast();
const [status, setStatus] = useState(null);
const [config, setConfig] = useState({ client_id: '', client_secret: '' });
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [connecting, setConnecting] = useState(false);
const loadData = useCallback(async () => {
setLoading(true);
try {
const statusData = await api.getGithubSourceStatus();
setStatus(statusData);
if (isAdmin) {
const configData = await api.getGithubSourceConfig();
setConfig({
client_id: configData.config?.client_id || '',
client_secret: configData.config?.client_secret || '',
});
}
} catch (err) {
toast.error(err.message || 'Failed to load source connections');
} finally {
setLoading(false);
}
}, [isAdmin, toast]);
useEffect(() => {
loadData();
}, [loadData]);
async function handleSaveConfig(e) {
e.preventDefault();
setSaving(true);
try {
const result = await api.updateGithubSourceConfig(config);
setConfig({
client_id: result.config?.client_id || '',
client_secret: result.config?.client_secret || '',
});
toast.success('GitHub connection settings saved');
await loadData();
} catch (err) {
toast.error(err.message || 'Failed to save GitHub settings');
} finally {
setSaving(false);
}
}
async function handleConnect() {
setConnecting(true);
try {
const redirectUri = `${window.location.origin}/connections/callback/github`;
sessionStorage.setItem('sourceConnectionReturnTo', '/settings/connections');
const { auth_url } = await api.startSourceConnection('github', redirectUri);
window.location.href = auth_url;
} catch (err) {
toast.error(err.message || 'Failed to start GitHub connection');
setConnecting(false);
}
}
async function handleDisconnect() {
try {
await api.disconnectSourceConnection('github');
toast.success('GitHub disconnected');
await loadData();
} catch (err) {
toast.error(err.message || 'Failed to disconnect GitHub');
}
}
const connection = status?.connection;
const configured = status?.configured;
if (loading) {
return <div className="settings-section"><p>Loading source connections...</p></div>;
}
return (
<div className="settings-section source-connections">
<div className="section-header">
<h2>Connections</h2>
<p>Connect source providers so services can be created from repository pickers instead of clone URLs.</p>
</div>
<div className="settings-card source-connection-card">
<div className="settings-card__header">
<div className="settings-card__header-left">
<Github size={22} />
<div>
<h3>GitHub</h3>
<p>Use the GitHub API to list repositories and import selected branches.</p>
</div>
</div>
<span className={`source-connection-status ${connection ? 'source-connection-status--connected' : ''}`}>
{connection ? <CheckCircle2 size={14} /> : <PlugZap size={14} />}
{connection ? 'Connected' : 'Not connected'}
</span>
</div>
{connection ? (
<div className="source-connection-profile">
{connection.avatar_url && <img src={connection.avatar_url} alt="" />}
<div>
<strong>{connection.display_name || connection.provider_username}</strong>
<span>@{connection.provider_username}</span>
</div>
<Button type="button" variant="outline" onClick={handleDisconnect}>
<Trash2 size={16} />
Disconnect
</Button>
</div>
) : (
<div className="source-connection-empty">
<Link2 size={20} />
<div>
<strong>{configured ? 'Connect your GitHub account' : 'GitHub OAuth is not configured'}</strong>
<span>
{configured
? 'Authorize ServerKit once, then pick repositories directly on the New Service page.'
: 'An admin needs to add a GitHub OAuth client before users can connect.'}
</span>
</div>
<Button type="button" onClick={handleConnect} disabled={!configured || connecting}>
<Github size={16} />
{connecting ? 'Redirecting...' : 'Connect GitHub'}
</Button>
</div>
)}
</div>
{isAdmin && (
<form className="settings-card source-oauth-config" onSubmit={handleSaveConfig}>
<div className="settings-card__header">
<div className="settings-card__header-left">
<KeyRound size={22} />
<div>
<h3>GitHub OAuth App</h3>
<p>Set the OAuth app credentials used for repository connections.</p>
</div>
</div>
</div>
<div className="source-oauth-config__grid">
<div className="form-group">
<Label htmlFor="github-source-client-id">Client ID</Label>
<Input
id="github-source-client-id"
value={config.client_id}
onChange={(e) => setConfig(prev => ({ ...prev, client_id: e.target.value }))}
placeholder="GitHub OAuth client ID"
/>
</div>
<div className="form-group">
<Label htmlFor="github-source-client-secret">Client Secret</Label>
<Input
id="github-source-client-secret"
type="password"
value={config.client_secret}
onChange={(e) => setConfig(prev => ({ ...prev, client_secret: e.target.value }))}
placeholder="GitHub OAuth client secret"
/>
</div>
</div>
<div className="source-oauth-config__callback">
<Shield size={16} />
<span>Callback URL: {window.location.origin}/connections/callback/github</span>
</div>
<div className="form-actions">
<Button type="submit" disabled={saving}>
{saving ? 'Saving...' : 'Save GitHub OAuth App'}
</Button>
</div>
</form>
)}
</div>
);
};
export default SourceConnectionsTab;
@@ -0,0 +1,266 @@
// 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';
import {
CheckCircle2, ExternalLink, KeyRound, Link2, PlugZap, ShieldCheck, ShieldAlert, Trash2,
} from 'lucide-react';
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 { 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,
onAddDns, onRemoveDns, onTestDns,
}) {
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">
<DialogHeader>
<div className="conn-modal__title">
<span className="conn-modal__icon"><ProviderBrandIcon provider={provider.id} size={22} /></span>
<div>
<DialogTitle>{provider.name}</DialogTitle>
<DialogDescription>{provider.blurb}</DialogDescription>
</div>
</div>
</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 === '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>
)}
{!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>
{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>
)}
<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>
{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>
)}
</>
)}
</div>
</DialogContent>
</Dialog>
);
}
function GithubBody({ status, isAdmin, callbackUrl, ghConfig, setGhConfig, busy, onConnect, onDisconnect, onSaveConfig, docUrl }) {
const connection = status?.connection;
const configured = status?.configured;
return (
<>
{connection ? (
<div className="conn-profile">
{connection.avatar_url && <img src={connection.avatar_url} alt="" />}
<div className="conn-profile__id">
<strong>{connection.display_name || connection.provider_username}</strong>
<span>@{connection.provider_username}</span>
</div>
<Button type="button" variant="outline" size="sm" disabled={busy} onClick={onDisconnect}>
<Trash2 size={15} /> Disconnect
</Button>
</div>
) : (
<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>
<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
</Button>
</div>
)}
{isAdmin && (
<form className="conn-form" onSubmit={onSaveConfig}>
<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" />
</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" />
</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
</a>
)}
<div className="conn-form__actions">
<Button type="submit" size="sm" disabled={busy}>{busy ? 'Saving…' : 'Save OAuth app'}</Button>
</div>
</form>
)}
</>
);
}
@@ -0,0 +1,223 @@
// 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.
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';
import {
CONNECTION_CATEGORIES, CONNECTION_PROVIDERS, deriveScope, dedupeScopes,
} from './providerCatalog';
import ProviderCard from './ProviderCard';
import ConnectProviderModal from './ConnectProviderModal';
export default function ConnectionsHub() {
const { isAdmin } = useAuth();
const toast = useToast();
const [githubStatus, setGithubStatus] = useState(null);
const [githubConfig, setGithubConfig] = useState(null);
const [dnsProviders, setDnsProviders] = 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([
api.getGithubSourceStatus().catch(() => null),
api.getEmailDNSProviders().then((d) => d.providers || []).catch(() => []),
]);
setGithubStatus(status);
setDnsProviders(dns);
if (isAdmin) {
const cfg = await api.getGithubSourceConfig().catch(() => null);
setGithubConfig(cfg?.config || { client_id: '', client_secret: '' });
}
} finally {
setLoading(false);
}
}, [isAdmin]);
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 () => {
try {
const redirectUri = `${window.location.origin}/connections/callback/github`;
sessionStorage.setItem('sourceConnectionReturnTo', '/settings/connections');
const { auth_url } = await api.startSourceConnection('github', redirectUri);
window.location.href = auth_url;
} catch (err) {
toast.error(err.message || 'Failed to start GitHub connection');
}
}, [toast]);
const onDisconnectGithub = useCallback(async () => {
try {
await api.disconnectSourceConnection('github');
toast.success('GitHub disconnected');
const status = await api.getGithubSourceStatus().catch(() => null);
setGithubStatus(status);
setModalOpen(false);
} catch (err) {
toast.error(err.message || 'Failed to disconnect GitHub');
}
}, [toast]);
const onSaveGithubConfig = useCallback(async (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');
return true;
} catch (err) {
toast.error(err.message || 'Failed to save GitHub OAuth app');
return false;
}
}, [toast]);
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();
return true;
} catch (err) {
toast.error(err.message || 'Failed to add connection');
return false;
}
}, [toast, refreshDns]);
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();
return true;
} catch (err) {
toast.error(err.message || 'Failed to remove connection');
return false;
}
}, [toast, refreshDns]);
const onTestDns = useCallback(async (id) => {
try {
const res = await api.testEmailDNSProvider(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 summaries for the cards ──
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 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]);
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>
) : (
CONNECTION_CATEGORIES.map((cat) => {
const providers = CONNECTION_PROVIDERS.filter((p) => p.category === cat.key);
if (!providers.length) return null;
return (
<section key={cat.key} className="connections-hub__category">
<header className="connections-hub__category-head">
<h3>{cat.label}</h3>
<p>{cat.blurb}</p>
</header>
<div className="connections-hub__grid">
{providers.map((provider) => (
<ProviderCard
key={provider.id}
provider={provider}
summary={summaries[provider.id]}
onManage={handleManage}
/>
))}
</div>
</section>
);
})
)}
<ConnectProviderModal
provider={modalProvider}
open={modalOpen}
onOpenChange={setModalOpen}
githubStatus={githubStatus}
githubConfig={githubConfig}
dnsProviders={dnsProviders}
isAdmin={isAdmin}
onConnectGithub={onConnectGithub}
onDisconnectGithub={onDisconnectGithub}
onSaveGithubConfig={onSaveGithubConfig}
onAddDns={onAddDns}
onRemoveDns={onRemoveDns}
onTestDns={onTestDns}
/>
</div>
);
}
@@ -0,0 +1,54 @@
// 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';
import { ProviderBrandIcon } from '../../icons/ProviderBrands';
import { Button } from '@/components/ui/button';
export default function ProviderCard({ provider, summary, onManage }) {
const comingSoon = provider.comingSoon;
const connected = !comingSoon && summary?.connected;
const statusLabel = comingSoon ? 'Soon' : (summary?.statusLabel || 'Not connected');
const statusTone = comingSoon ? 'soon' : (summary?.statusTone || 'neutral');
return (
<div className={`conn-card${comingSoon ? ' conn-card--soon' : ''}${connected ? ' conn-card--connected' : ''}`}>
<div className="conn-card__top">
<span className="conn-card__icon">
<ProviderBrandIcon provider={provider.id} size={22} />
</span>
<div className="conn-card__heading">
<h4 className="conn-card__name">{provider.name}</h4>
<p className="conn-card__blurb">{provider.blurb}</p>
</div>
<span className={`conn-status conn-status--${statusTone}`}>{statusLabel}</span>
</div>
{connected && (summary.subtitle || summary.scopes?.length > 0) && (
<div className="conn-card__meta">
{summary.subtitle && <span className="conn-card__subtitle">{summary.subtitle}</span>}
{summary.scopes?.length > 0 && (
<span className="conn-card__scopes">
{summary.scopes.map((s, i) => (
<span key={i} className={`conn-pill conn-pill--${s.tone}`} title={s.hint}>{s.label}</span>
))}
</span>
)}
</div>
)}
<div className="conn-card__actions">
{comingSoon ? (
<span className="conn-card__soon-tag">Coming soon</span>
) : (
<Button variant={connected ? 'outline' : 'default'} size="sm" onClick={() => onManage(provider)}>
{connected ? 'Manage' : 'Connect'}
<ArrowRight size={15} />
</Button>
)}
</div>
</div>
);
}
@@ -0,0 +1,75 @@
// 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.
export const CONNECTION_CATEGORIES = [
{ key: 'source', label: 'Source code', blurb: 'Create services straight from a repository instead of pasting clone URLs.' },
{ key: 'dns', label: 'DNS & domains', blurb: 'Let ServerKit manage DNS records and issue wildcard certificates automatically.' },
{ 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 = [
{
id: 'github', category: 'source', name: 'GitHub', kind: 'github',
blurb: 'List repositories over the GitHub API and import selected branches.',
docUrl: 'https://github.com/settings/developers',
},
{
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',
},
{
id: 'route53', category: 'dns', name: 'Route 53', kind: 'dns', provider: 'route53',
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.' },
];
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.
export function deriveScope(record) {
if (!record) return null;
if (record.provider === 'cloudflare') {
return record.api_email
? { label: 'Global key', tone: 'warn', hint: 'Full account access' }
: { label: 'Scoped token', tone: 'ok', hint: 'Least privilege' };
}
if (record.provider === 'route53') {
return { label: 'Access key', tone: 'neutral', hint: 'Scope set by IAM policy' };
}
return null;
}
// Collapse a list of scope descriptors to unique labels so a card with three
// scoped tokens shows one "Scoped token" chip, not three.
export function dedupeScopes(scopes) {
const seen = new Set();
const out = [];
for (const s of scopes) {
if (!s || seen.has(s.label)) continue;
seen.add(s.label);
out.push(s);
}
return out;
}
+2 -2
View File
@@ -13,7 +13,7 @@ import UsersTab from '../components/settings/UsersTab';
import ActivityTab from '../components/settings/ActivityTab';
import SiteSettingsTab from '../components/settings/SiteSettingsTab';
import SSOConfigTab from '../components/settings/SSOConfigTab';
import SourceConnectionsTab from '../components/settings/SourceConnectionsTab';
import ConnectionsHub from '../components/settings/connections/ConnectionsHub';
import ApiSettingsTab from '../components/settings/ApiSettingsTab';
import MigrationHistoryTab from '../components/settings/MigrationHistoryTab';
import IconReferenceTab from '../components/settings/IconReferenceTab';
@@ -262,7 +262,7 @@ const Settings = () => {
<div className="settings-content">
{activeTab === 'profile' && <ProfileTab />}
{activeTab === 'security' && <SecuritySettingsTab />}
{activeTab === 'connections' && <SourceConnectionsTab />}
{activeTab === 'connections' && <ConnectionsHub />}
{activeTab === 'appearance' && <AppearanceTab />}
{activeTab === 'sidebar' && <SidebarSettings />}
{activeTab === 'whitelabel' && <WhiteLabelTab />}
+1
View File
@@ -81,6 +81,7 @@
@import 'pages/_deployments';
@import 'pages/_terminal';
@import 'pages/_settings';
@import 'pages/_connections';
@import 'pages/_file-manager';
@import 'pages/_ftp-server';
@import 'pages/_firewall';
+513
View File
@@ -0,0 +1,513 @@
// ============================================
// CONNECTIONS HUB — Settings → Connections
// A categorized catalog of external integrations (source hosts, DNS, email,
// storage) that replaces the old GitHub-only tab. Visual language mirrors the
// "connect a repository" surfaces (_git-connect.scss): brand marks, accent
// tiles, and a scope-aware connect flow.
// ============================================
.connections-hub {
display: flex;
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;
color: $text-tertiary;
font-size: $font-size-sm;
}
&__category {
display: flex;
flex-direction: column;
gap: $space-3;
}
&__category-head {
h3 {
margin: 0;
font-size: $font-size-base;
font-weight: $font-weight-semibold;
color: $text-primary;
}
p {
margin: 2px 0 0;
font-size: $font-size-xs;
color: $text-tertiary;
}
}
&__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: $space-3;
}
}
// ── Provider card ──
.conn-card {
display: flex;
flex-direction: column;
gap: $space-3;
padding: $space-4;
background: $bg-card;
border: 1px solid $border-subtle;
border-radius: $radius-lg;
transition: border-color $transition-fast, background $transition-fast;
&:hover {
border-color: $border-active;
}
&--soon {
opacity: 0.7;
&:hover {
border-color: $border-subtle;
}
}
&__top {
display: flex;
align-items: flex-start;
gap: $space-3;
}
&__icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 40px;
height: 40px;
border-radius: $radius-md;
background: var(--surface-2);
color: $text-primary;
}
&__heading {
flex: 1;
min-width: 0;
}
&__name {
margin: 0;
font-size: $font-size-sm;
font-weight: $font-weight-semibold;
color: $text-primary;
}
&__blurb {
margin: 2px 0 0;
font-size: $font-size-xs;
color: $text-tertiary;
line-height: 1.45;
}
&__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: $space-2;
}
&__subtitle {
font-size: $font-size-xs;
color: $text-secondary;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
&__scopes {
display: inline-flex;
flex-wrap: wrap;
gap: $space-1;
}
&__actions {
display: flex;
align-items: center;
justify-content: flex-end;
margin-top: auto;
button {
display: inline-flex;
align-items: center;
gap: $space-1;
}
}
&__soon-tag {
font-size: $font-size-xs;
font-weight: $font-weight-medium;
color: $text-tertiary;
}
}
// ── Status pill (top-right of a card) ──
.conn-status {
flex-shrink: 0;
display: inline-flex;
align-items: center;
padding: 2px $space-2;
border: 1px solid $border-subtle;
border-radius: $radius-full;
font-size: $font-size-xs;
font-weight: $font-weight-medium;
color: $text-tertiary;
white-space: nowrap;
&--ok {
color: $success;
background: $success-bg;
border-color: $success-border;
}
}
// ── Scope chip (access level) ──
.conn-pill {
display: inline-flex;
align-items: center;
padding: 1px $space-2;
border: 1px solid $border-subtle;
border-radius: $radius-full;
font-size: 11px;
font-weight: $font-weight-medium;
color: $text-secondary;
&--ok {
color: $success;
background: $success-bg;
border-color: $success-border;
}
&--warn {
color: $warning;
background: fade($warning, 12%);
border-color: fade($warning, 28%);
}
&--neutral {
color: $text-tertiary;
}
}
// ── Connect / manage modal ──
.ui-dialog-content.conn-modal {
max-width: 560px;
}
.conn-modal {
&__title {
display: flex;
align-items: flex-start;
gap: $space-3;
}
&__icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 38px;
height: 38px;
border-radius: $radius-md;
background: var(--surface-2);
color: $text-primary;
}
&__body {
display: flex;
flex-direction: column;
gap: $space-4;
margin-top: $space-2;
max-height: 62vh;
overflow-y: auto;
}
&__note {
display: flex;
align-items: center;
gap: $space-2;
margin: 0;
font-size: $font-size-sm;
color: $text-tertiary;
}
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
}
// GitHub: connected profile + not-connected CTA
.conn-profile,
.conn-empty {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: $space-3;
padding: $space-3 $space-4;
background: $bg-elevated;
border: 1px solid $border-subtle;
border-radius: $radius-md;
}
.conn-profile {
img {
width: 40px;
height: 40px;
border-radius: $radius-full;
}
&__id {
min-width: 0;
strong {
display: block;
color: $text-primary;
font-size: $font-size-sm;
font-weight: $font-weight-semibold;
}
span {
display: block;
margin-top: 2px;
color: $text-tertiary;
font-size: $font-size-xs;
}
}
}
.conn-empty {
&__icon {
display: inline-flex;
color: var(--accent-bright);
}
&__text {
min-width: 0;
strong {
display: block;
color: $text-primary;
font-size: $font-size-sm;
font-weight: $font-weight-semibold;
}
span {
display: block;
margin-top: 3px;
color: $text-tertiary;
font-size: $font-size-xs;
line-height: 1.45;
}
}
}
// DNS: existing connection rows
.conn-list {
display: flex;
flex-direction: column;
gap: $space-2;
&__row {
display: flex;
align-items: center;
gap: $space-3;
padding: $space-3;
background: $bg-elevated;
border: 1px solid $border-subtle;
border-radius: $radius-md;
}
&__info {
flex: 1;
min-width: 0;
strong {
display: block;
color: $text-primary;
font-size: $font-size-sm;
font-weight: $font-weight-semibold;
}
}
&__key {
display: block;
margin-top: 2px;
font-family: $font-mono;
font-size: 11px;
color: $text-tertiary;
}
&__actions {
display: flex;
align-items: center;
gap: $space-1;
flex-shrink: 0;
}
}
// Connect / add form (shared by GitHub config + DNS add)
.conn-form {
display: flex;
flex-direction: column;
gap: $space-3;
padding: $space-4;
background: $bg-elevated;
border: 1px solid $border-subtle;
border-radius: $radius-md;
&__heading {
display: flex;
align-items: center;
gap: $space-2;
font-size: $font-size-sm;
font-weight: $font-weight-semibold;
color: $text-primary;
}
&__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: $space-3;
}
&__wide {
grid-column: 1 / -1;
}
&__callback {
display: flex;
align-items: center;
gap: $space-2;
flex-wrap: wrap;
font-size: $font-size-xs;
color: $text-tertiary;
code {
font-family: $font-mono;
color: $text-secondary;
overflow-wrap: anywhere;
}
}
&__doc {
display: inline-flex;
align-items: center;
gap: $space-1;
width: max-content;
font-size: $font-size-xs;
color: var(--accent-bright);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
&__actions {
display: flex;
justify-content: flex-end;
}
}
// Access-level picker (Cloudflare scoped token vs global key)
.conn-scope {
display: grid;
grid-template-columns: 1fr 1fr;
gap: $space-2;
&__opt {
display: flex;
flex-direction: column;
gap: $space-1;
padding: $space-3;
text-align: left;
background: $bg-card;
border: 1px solid $border-subtle;
border-radius: $radius-md;
cursor: pointer;
transition: border-color $transition-fast, background $transition-fast;
&:hover {
border-color: $border-active;
}
&.is-active {
border-color: var(--accent-dim);
background: var(--accent-bg);
}
}
&__head {
display: flex;
align-items: center;
gap: $space-1;
font-size: $font-size-sm;
font-weight: $font-weight-semibold;
color: $text-primary;
}
&__rec {
margin-left: auto;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.04em;
font-weight: $font-weight-semibold;
color: $success;
}
&__desc {
font-size: $font-size-xs;
color: $text-tertiary;
line-height: 1.45;
}
}
@media (max-width: $breakpoint-sm) {
.conn-scope,
.conn-form__grid {
grid-template-columns: 1fr;
}
}
+4
View File
@@ -114,6 +114,10 @@
gap: $space-5;
.section-header {
// Reset the global `.section-header { @include flex-between(); }` from
// _cards.scss — settings sections stack their title over the description
// instead of pushing the description to the far right.
display: block;
margin-bottom: $space-2;
h2 {