mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-26 04:57:08 +09:00
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:
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;
|
||||
}
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user