← back to Patty
components/settings/SocialCredentialsSection.tsx
421 lines
'use client';
import { useState, useEffect } from 'react';
import { Eye, EyeOff, Save, RefreshCw, CheckCircle2, XCircle, AlertTriangle, ChevronDown, ChevronRight, ExternalLink } from 'lucide-react';
/* ─── Platform definitions ──────────────────────────────────────────────── */
interface FieldDef {
key: string;
label: string;
placeholder: string;
help?: string;
}
interface PlatformDef {
id: string;
label: string;
color: string;
icon: string;
docsUrl: string;
instructions: { step: string; detail: string }[];
fields: FieldDef[];
note?: string;
}
const PLATFORMS: PlatformDef[] = [
{
id: 'twitter',
label: 'X / Twitter',
color: '#1DA1F2',
icon: '𝕏',
docsUrl: 'https://developer.twitter.com/en/portal/dashboard',
instructions: [
{ step: 'Go to developer.twitter.com', detail: 'Sign in with your Twitter/X account and open the Developer Portal.' },
{ step: 'Create a Project + App', detail: 'Click "+ New Project", name it, then create an App inside it. Choose "Production" environment.' },
{ step: 'Set app permissions', detail: 'In App Settings → User authentication settings, enable OAuth 1.0a with Read & Write permissions.' },
{ step: 'Get API Key & Secret', detail: 'In "Keys and Tokens" → Consumer Keys section. Copy API Key and API Key Secret.' },
{ step: 'Get Access Token & Secret', detail: 'Still in Keys and Tokens → Authentication Tokens section. Click "Generate" to get Access Token and Access Token Secret.' },
{ step: 'Get Bearer Token', detail: 'In the same Keys and Tokens page, copy the Bearer Token (used for read-only operations).' },
],
fields: [
{ key: 'TWITTER_API_KEY', label: 'API Key (Consumer Key)', placeholder: 'xxxxxxxxxxxxxxxxxxxxxxxxx' },
{ key: 'TWITTER_API_SECRET', label: 'API Key Secret (Consumer Secret)', placeholder: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' },
{ key: 'TWITTER_ACCESS_TOKEN', label: 'Access Token', placeholder: '000000000-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' },
{ key: 'TWITTER_ACCESS_SECRET', label: 'Access Token Secret', placeholder: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' },
{ key: 'TWITTER_BEARER_TOKEN', label: 'Bearer Token (for reading)', placeholder: 'AAAAAAAAAAAAAAAAAAAAAxxxxxxxxxxxxxxxx', help: 'Optional — only needed for search/discover features' },
],
},
{
id: 'reddit',
label: 'Reddit',
color: '#FF4500',
icon: '🤖',
docsUrl: 'https://www.reddit.com/prefs/apps',
instructions: [
{ step: 'Go to reddit.com/prefs/apps', detail: 'Log in to the Reddit account that will post. Go to Preferences → Apps.' },
{ step: 'Create an app', detail: 'Click "create another app..." at the bottom. Choose type "script" (for personal use).' },
{ step: 'Fill in the form', detail: 'Name: anything (e.g. "PetitionBot"). Redirect URI: http://localhost:8080. Click "create app".' },
{ step: 'Get Client ID', detail: 'The Client ID is the short string directly under the app name (below "personal use script").' },
{ step: 'Get Client Secret', detail: 'The Client Secret is labeled "secret" in the app details.' },
{ step: 'Username & Password', detail: 'These are the Reddit account credentials that will be posting. The account must be at least 30 days old to post in most subreddits.' },
],
fields: [
{ key: 'REDDIT_CLIENT_ID', label: 'Client ID', placeholder: 'xxxxxxxxxxxxxx' },
{ key: 'REDDIT_CLIENT_SECRET', label: 'Client Secret', placeholder: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' },
{ key: 'REDDIT_USERNAME', label: 'Reddit Username', placeholder: 'u/your_username (without u/)' },
{ key: 'REDDIT_PASSWORD', label: 'Reddit Password', placeholder: 'your reddit account password' },
],
},
{
id: 'discord',
label: 'Discord',
color: '#5865F2',
icon: '💬',
docsUrl: 'https://discord.com/developers/applications',
instructions: [
{ step: 'Go to discord.com/developers/applications', detail: 'Sign in with a Discord account that has admin access to your server.' },
{ step: 'New Application', detail: 'Click "New Application", give it a name (e.g. "PetitionBot"). Click Create.' },
{ step: 'Create a Bot', detail: 'In the left sidebar, click "Bot". Click "Add Bot" then confirm. This creates the bot user.' },
{ step: 'Get Bot Token', detail: 'Click "Reset Token" to reveal the bot token. Copy it — you won\'t see it again without resetting.' },
{ step: 'Set bot permissions', detail: 'Under "Privileged Gateway Intents", enable Message Content Intent. Also enable Server Members Intent.' },
{ step: 'Invite bot to server', detail: 'Go to OAuth2 → URL Generator. Select scopes: bot. Permissions: Send Messages, Embed Links. Copy and open the URL to add bot to your server.' },
{ step: 'Get Channel ID', detail: 'In Discord, enable Developer Mode (User Settings → Advanced). Right-click any channel → "Copy Channel ID".' },
],
fields: [
{ key: 'DISCORD_BOT_TOKEN', label: 'Bot Token', placeholder: 'MTxxxxxxxxxx.xxxxxx.xxxxxxxxxxxxxxxxxxxxxxxxxxx' },
{ key: 'DISCORD_DEFAULT_CHANNEL_ID', label: 'Default Channel ID', placeholder: '000000000000000000', help: 'Right-click a channel in Discord → Copy Channel ID (requires Developer Mode enabled)' },
],
},
{
id: 'bluesky',
label: 'Bluesky',
color: '#0085FF',
icon: '🦋',
docsUrl: 'https://bsky.app/settings/app-passwords',
instructions: [
{ step: 'Go to bsky.app/settings/app-passwords', detail: 'Sign in to Bluesky and navigate to Settings → App Passwords.' },
{ step: 'Add App Password', detail: 'Click "+ Add App Password". Give it a name like "PetitionBot". Click "Create App Password".' },
{ step: 'Copy the password', detail: 'The app password is shown only once. Copy it immediately — it looks like: xxxx-xxxx-xxxx-xxxx' },
{ step: 'Your handle', detail: 'Your Bluesky handle is shown at bsky.app/profile. Format: yourname.bsky.social (include .bsky.social)' },
],
fields: [
{ key: 'BSKY_HANDLE', label: 'Bluesky Handle', placeholder: 'yourname.bsky.social', help: 'Include the full domain, e.g. yourorg.bsky.social' },
{ key: 'BSKY_PASSWORD', label: 'App Password', placeholder: 'xxxx-xxxx-xxxx-xxxx', help: 'Use an App Password, NOT your main account password' },
],
note: 'Use an App Password — never your main account password. App Passwords have limited scope and can be revoked independently.',
},
];
/* ─── Helper: masked input ────────────────────────────────────────────────── */
function SecretInput({
value, onChange, placeholder,
}: { value: string; onChange: (v: string) => void; placeholder: string }) {
const [show, setShow] = useState(false);
const isMasked = value.includes('*');
return (
<div className="relative">
<input
type={show ? 'text' : 'password'}
className="input w-full pr-8 font-mono text-xs"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
style={{ letterSpacing: show ? 'normal' : undefined }}
onFocus={() => { if (isMasked) onChange(''); }}
/>
<button
type="button"
onClick={() => setShow((s) => !s)}
className="absolute right-2 top-1/2 -translate-y-1/2"
style={{ color: 'var(--color-text-muted)' }}
>
{show ? <EyeOff size={13} /> : <Eye size={13} />}
</button>
</div>
);
}
/* ─── Platform card ──────────────────────────────────────────────────────── */
function PlatformCard({
platform,
initialValues,
onSaved,
}: {
platform: PlatformDef;
initialValues: Record<string, string>;
onSaved: () => void;
}) {
const [expanded, setExpanded] = useState(false);
const [showInstructions, setShowInstructions] = useState(false);
const [values, setValues] = useState<Record<string, string>>(() => {
const v: Record<string, string> = {};
for (const f of platform.fields) v[f.key] = initialValues[f.key] || '';
return v;
});
const [saving, setSaving] = useState(false);
const [status, setStatus] = useState<'idle' | 'saved' | 'error'>('idle');
const [statusMsg, setStatusMsg] = useState('');
const isConfigured = platform.fields.some((f) => values[f.key] && !values[f.key].includes('*'));
const hasAnyMasked = platform.fields.some((f) => initialValues[f.key]?.includes('*'));
async function handleSave() {
setSaving(true);
try {
const res = await fetch('/api/settings/credentials', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ platform: platform.id, credentials: values }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Save failed');
setStatus('saved');
setStatusMsg(data.restarted ? '✓ Saved & agent restarted' : '✓ Saved (agent restart failed — restart manually)');
onSaved();
} catch (err) {
setStatus('error');
setStatusMsg((err as Error).message);
} finally {
setSaving(false);
setTimeout(() => setStatus('idle'), 4000);
}
}
return (
<div
className="rounded-lg overflow-hidden"
style={{ border: `1px solid ${expanded ? platform.color + '44' : 'var(--color-border)'}`, background: 'var(--color-surface)' }}
>
{/* Header */}
<button
className="w-full flex items-center gap-3 px-4 py-3 text-left"
style={{ background: expanded ? `${platform.color}0d` : 'transparent' }}
onClick={() => setExpanded((e) => !e)}
>
<span className="text-base w-6 text-center">{platform.icon}</span>
<span className="text-sm font-semibold flex-1" style={{ color: 'var(--color-text)' }}>{platform.label}</span>
{/* Status badge */}
{hasAnyMasked ? (
<span className="flex items-center gap-1 text-xs px-2 py-0.5 rounded-full" style={{ background: 'rgba(34,197,94,0.12)', color: '#22c55e' }}>
<CheckCircle2 size={10} /> Configured
</span>
) : (
<span className="flex items-center gap-1 text-xs px-2 py-0.5 rounded-full" style={{ background: 'rgba(245,158,11,0.12)', color: '#f59e0b' }}>
<AlertTriangle size={10} /> Not configured
</span>
)}
{expanded ? <ChevronDown size={14} style={{ color: 'var(--color-text-muted)' }} /> : <ChevronRight size={14} style={{ color: 'var(--color-text-muted)' }} />}
</button>
{expanded && (
<div className="px-4 pb-4 space-y-4" style={{ borderTop: `1px solid ${platform.color}22` }}>
{/* Instructions toggle */}
<div className="mt-3">
<button
className="flex items-center gap-2 text-xs font-medium"
style={{ color: platform.color }}
onClick={() => setShowInstructions((s) => !s)}
>
{showInstructions ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
How to get these credentials
<a
href={platform.docsUrl}
target="_blank"
rel="noreferrer"
onClick={(e) => e.stopPropagation()}
className="ml-1 flex items-center gap-1 opacity-60 hover:opacity-100"
>
<ExternalLink size={10} />
Open {platform.label}
</a>
</button>
{showInstructions && (
<ol className="mt-2 space-y-2">
{platform.instructions.map((ins, i) => (
<li key={i} className="flex gap-3 text-xs" style={{ color: 'var(--color-text-secondary)' }}>
<span
className="flex-shrink-0 w-5 h-5 rounded-full flex items-center justify-center text-xs font-bold mt-0.5"
style={{ background: `${platform.color}22`, color: platform.color }}
>
{i + 1}
</span>
<div>
<span className="font-semibold" style={{ color: 'var(--color-text)' }}>{ins.step}</span>
<span className="text-xs ml-1" style={{ color: 'var(--color-text-muted)' }}>— {ins.detail}</span>
</div>
</li>
))}
</ol>
)}
</div>
{/* Platform note */}
{platform.note && (
<div
className="text-xs px-3 py-2 rounded"
style={{ background: `${platform.color}10`, color: platform.color, border: `1px solid ${platform.color}30` }}
>
⚠ {platform.note}
</div>
)}
{/* Credential fields */}
<div className="space-y-3">
{platform.fields.map((field) => (
<div key={field.key}>
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>
{field.label}
</label>
<SecretInput
value={values[field.key] || ''}
onChange={(v) => setValues((prev) => ({ ...prev, [field.key]: v }))}
placeholder={field.placeholder}
/>
{field.help && (
<p className="text-xs mt-1" style={{ color: 'var(--color-text-muted)' }}>{field.help}</p>
)}
</div>
))}
</div>
{/* Save button + status */}
<div className="flex items-center gap-3 pt-1">
<button
className="btn btn-primary btn-sm"
onClick={handleSave}
disabled={saving}
style={{ background: platform.color, borderColor: platform.color }}
>
{saving ? (
<><RefreshCw size={12} className="animate-spin" /><span>Saving...</span></>
) : (
<><Save size={12} /><span>Save & Apply to Agent</span></>
)}
</button>
{status !== 'idle' && (
<span
className="text-xs flex items-center gap-1"
style={{ color: status === 'saved' ? '#22c55e' : '#ef4444' }}
>
{status === 'saved' ? <CheckCircle2 size={12} /> : <XCircle size={12} />}
{statusMsg}
</span>
)}
</div>
</div>
)}
</div>
);
}
/* ─── MoveOn card (no credentials — instructions only) ───────────────────── */
function MoveOnCard() {
const [expanded, setExpanded] = useState(false);
return (
<div
className="rounded-lg overflow-hidden"
style={{ border: `1px solid ${expanded ? '#E0313144' : 'var(--color-border)'}`, background: 'var(--color-surface)' }}
>
<button
className="w-full flex items-center gap-3 px-4 py-3 text-left"
style={{ background: expanded ? '#E031310d' : 'transparent' }}
onClick={() => setExpanded((e) => !e)}
>
<span className="text-base w-6 text-center">📋</span>
<span className="text-sm font-semibold flex-1" style={{ color: 'var(--color-text)' }}>MoveOn</span>
<span className="flex items-center gap-1 text-xs px-2 py-0.5 rounded-full" style={{ background: 'rgba(107,114,128,0.15)', color: '#6b7280' }}>
Manual only
</span>
{expanded ? <ChevronDown size={14} style={{ color: 'var(--color-text-muted)' }} /> : <ChevronRight size={14} style={{ color: 'var(--color-text-muted)' }} />}
</button>
{expanded && (
<div className="px-4 pb-4 space-y-3" style={{ borderTop: '1px solid #E0313122' }}>
<div className="mt-3 text-xs rounded-lg p-3 space-y-2" style={{ background: 'rgba(239,68,68,0.06)', border: '1px solid rgba(239,68,68,0.18)', color: '#f87171' }}>
<p className="font-semibold">MoveOn does not have a public petition creation API.</p>
<p style={{ color: 'var(--color-text-muted)' }}>
The MoveOn agent prepares a pre-filled petition URL with your title and target organization. You must open the link and complete submission manually, or Playwright browser automation can be added to automate the form.
</p>
</div>
<div>
<p className="text-xs font-semibold mb-2" style={{ color: 'var(--color-text-secondary)' }}>How the MoveOn workflow works:</p>
<ol className="space-y-2">
{[
{ s: 'Generate petition', d: 'The agent generates your petition title, body, and target organization.' },
{ s: 'Get the pre-filled URL', d: 'After dispatch, the agent returns a MoveOn URL like: sign.moveon.org/create/?title=...&target=...' },
{ s: 'Open the URL', d: 'Click the link — it opens MoveOn with the title and target pre-filled.' },
{ s: 'Complete the form', d: 'Paste in the petition body, select category, add your name/email, then submit.' },
{ s: 'Share the petition link', d: 'After creation, copy the petition URL and use it in Twitter/Reddit/Discord posts.' },
].map((item, i) => (
<li key={i} className="flex gap-3 text-xs" style={{ color: 'var(--color-text-secondary)' }}>
<span className="flex-shrink-0 w-5 h-5 rounded-full flex items-center justify-center text-xs font-bold mt-0.5" style={{ background: 'rgba(224,49,49,0.15)', color: '#f87171' }}>
{i + 1}
</span>
<div>
<span className="font-semibold" style={{ color: 'var(--color-text)' }}>{item.s}</span>
<span className="ml-1" style={{ color: 'var(--color-text-muted)' }}>— {item.d}</span>
</div>
</li>
))}
</ol>
</div>
</div>
)}
</div>
);
}
/* ─── Main export ─────────────────────────────────────────────────────────── */
export default function SocialCredentialsSection() {
const [maskedValues, setMaskedValues] = useState<Record<string, Record<string, string>>>({});
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchCreds();
}, []);
async function fetchCreds() {
try {
const res = await fetch('/api/settings/credentials');
if (!res.ok) throw new Error('Failed');
const data = await res.json();
setMaskedValues(data.credentials || {});
} catch {
// silent — show empty fields
} finally {
setLoading(false);
}
}
if (loading) {
return (
<div className="space-y-2">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="skeleton h-12 rounded-lg" />
))}
</div>
);
}
return (
<div className="space-y-2">
{PLATFORMS.map((p) => (
<PlatformCard
key={p.id}
platform={p}
initialValues={maskedValues[p.id] || {}}
onSaved={fetchCreds}
/>
))}
<MoveOnCard />
</div>
);
}