← 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>
  );
}