← back to Norma
components/apis/ApisTab.tsx
1019 lines
'use client';
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Mail,
HardDrive,
Hash,
Palette,
ClipboardCheck,
Users,
Key,
ExternalLink,
CheckCircle2,
AlertCircle,
Copy,
Eye,
EyeOff,
Save,
RefreshCw,
Shield,
ChevronDown,
ChevronRight,
Info,
Link2,
Zap,
} from 'lucide-react';
import { useToast } from '../ToastProvider';
/* ─── Types ──────────────────────────────────────────────────────────────── */
type ConnectionStatus = 'connected' | 'partial' | 'not_connected' | 'error';
interface ApiScope {
name: string;
description: string;
required: boolean;
}
interface ApiCredentialField {
key: string;
label: string;
type: 'text' | 'password' | 'url';
placeholder: string;
helpText?: string;
}
interface ApiService {
id: string;
name: string;
description: string;
Icon: React.ElementType;
color: string;
status: ConnectionStatus;
consoleUrl: string;
docsUrl: string;
scopes: ApiScope[];
credentialFields: ApiCredentialField[];
setupSteps: string[];
useCases: string[];
}
/* ─── Service definitions ────────────────────────────────────────────────── */
const API_SERVICES: ApiService[] = [
{
id: 'gmail',
name: 'Gmail API',
description: 'Send emails, manage drafts, read inbox for grant correspondence',
Icon: Mail,
color: '#ea4335',
status: 'not_connected',
consoleUrl: 'https://console.cloud.google.com/apis/library/gmail.googleapis.com',
docsUrl: 'https://developers.google.com/gmail/api/reference/rest',
scopes: [
{ name: 'gmail.send', description: 'Send emails on behalf of the org', required: true },
{ name: 'gmail.readonly', description: 'Read emails and threads', required: true },
{ name: 'gmail.compose', description: 'Create and manage drafts', required: true },
{ name: 'gmail.modify', description: 'Modify labels and message metadata', required: false },
{ name: 'gmail.labels', description: 'Create and manage labels', required: false },
],
credentialFields: [
{ key: 'GOOGLE_CLIENT_ID', label: 'OAuth Client ID', type: 'text', placeholder: 'xxxx.apps.googleusercontent.com' },
{ key: 'GOOGLE_CLIENT_SECRET', label: 'OAuth Client Secret', type: 'password', placeholder: 'GOCSPX-...' },
{ key: 'GOOGLE_REFRESH_TOKEN', label: 'Refresh Token', type: 'password', placeholder: '1//0e...', helpText: 'Generated after OAuth consent flow' },
{ key: 'GMAIL_SEND_AS', label: 'Send-As Email', type: 'text', placeholder: 'info@your-org-email.org' },
],
setupSteps: [
'Go to Google Cloud Console → Create or select project',
'Enable Gmail API from the API Library',
'Create OAuth 2.0 credentials (Web Application type)',
'Add authorized redirect URI: http://45.61.58.125:7400/api/auth/google/callback',
'Configure OAuth consent screen (Internal or External)',
'Add scopes: gmail.send, gmail.readonly, gmail.compose',
'Run OAuth flow to obtain refresh token',
'Paste credentials below and save',
],
useCases: [
'Send grant proposals directly from the platform',
'Read incoming grant correspondence and auto-tag',
'Sync email threads related to active grants',
'Auto-draft follow-up emails for pending proposals',
],
},
{
id: 'workspace',
name: 'Google Workspace Admin',
description: 'Manage org users, groups, shared drives, and directory',
Icon: Users,
color: '#4285f4',
status: 'not_connected',
consoleUrl: 'https://console.cloud.google.com/apis/library/admin.googleapis.com',
docsUrl: 'https://developers.google.com/admin-sdk/directory/reference/rest',
scopes: [
{ name: 'admin.directory.user.readonly', description: 'Read org user directory', required: true },
{ name: 'admin.directory.group.readonly', description: 'Read org groups', required: true },
{ name: 'admin.directory.orgunit.readonly', description: 'Read org unit structure', required: false },
{ name: 'admin.directory.user', description: 'Manage org users (create, update)', required: false },
{ name: 'admin.directory.group', description: 'Manage org groups', required: false },
],
credentialFields: [
{ key: 'WORKSPACE_CLIENT_ID', label: 'OAuth Client ID', type: 'text', placeholder: 'xxxx.apps.googleusercontent.com', helpText: 'Can reuse the same Google Cloud project as Gmail' },
{ key: 'WORKSPACE_CLIENT_SECRET', label: 'OAuth Client Secret', type: 'password', placeholder: 'GOCSPX-...' },
{ key: 'WORKSPACE_ADMIN_EMAIL', label: 'Admin Email', type: 'text', placeholder: 'admin@your-org-email.org', helpText: 'Must be a Workspace super admin account' },
{ key: 'WORKSPACE_SERVICE_ACCOUNT', label: 'Service Account JSON', type: 'password', placeholder: '{"type":"service_account",...}', helpText: 'Domain-wide delegation required for Admin SDK' },
],
setupSteps: [
'Go to Google Cloud Console → Same project as Gmail',
'Enable Admin SDK API from the API Library',
'Create a service account with domain-wide delegation',
'In Google Admin Console → Security → API Controls → Domain-wide Delegation',
'Add the service account client ID with required scopes',
'Download service account JSON key file',
'Paste the JSON key contents below',
'Set the admin email to impersonate',
],
useCases: [
'Pull org directory for team assignments',
'Manage mailing lists for campaign distribution',
'Track active team members for collaboration features',
'Sync org structure for permission management',
],
},
{
id: 'drive',
name: 'Google Drive API',
description: 'Store proposals, attachments, 990s, and shared documents',
Icon: HardDrive,
color: '#0f9d58',
status: 'not_connected',
consoleUrl: 'https://console.cloud.google.com/apis/library/drive.googleapis.com',
docsUrl: 'https://developers.google.com/drive/api/reference/rest/v3',
scopes: [
{ name: 'drive.file', description: 'Access files created by the app', required: true },
{ name: 'drive.readonly', description: 'Read all files in Drive', required: true },
{ name: 'drive', description: 'Full Drive access (create, edit, delete)', required: false },
{ name: 'drive.metadata.readonly', description: 'Read file metadata only', required: false },
],
credentialFields: [
{ key: 'DRIVE_CLIENT_ID', label: 'OAuth Client ID', type: 'text', placeholder: 'xxxx.apps.googleusercontent.com', helpText: 'Can reuse same Google Cloud project' },
{ key: 'DRIVE_CLIENT_SECRET', label: 'OAuth Client Secret', type: 'password', placeholder: 'GOCSPX-...' },
{ key: 'DRIVE_REFRESH_TOKEN', label: 'Refresh Token', type: 'password', placeholder: '1//0e...' },
{ key: 'DRIVE_SHARED_FOLDER_ID', label: 'Shared Folder ID', type: 'text', placeholder: '1A2B3C...', helpText: 'Folder ID from the Org Grants shared folder URL' },
],
setupSteps: [
'Go to Google Cloud Console → Same project as Gmail',
'Enable Google Drive API from the API Library',
'Reuse existing OAuth 2.0 credentials or create new ones',
'Add Drive scopes to OAuth consent screen',
'Create a shared folder in Google Drive for Org Grants',
'Copy the folder ID from the URL (after /folders/)',
'Run OAuth flow with drive.file scope to obtain refresh token',
'Paste credentials and folder ID below',
],
useCases: [
'Auto-save generated proposals as Google Docs',
'Store grant attachments (budgets, 990s, board lists)',
'Organize past submissions by funder and date',
'Pull shared templates for proposal generation',
],
},
{
id: 'slack',
name: 'Slack API',
description: 'Team notifications, slash commands, channel messaging',
Icon: Hash,
color: '#4a154b',
status: 'not_connected',
consoleUrl: 'https://api.slack.com/apps',
docsUrl: 'https://api.slack.com/methods',
scopes: [
{ name: 'incoming-webhook', description: 'Post messages to channels', required: true },
{ name: 'commands', description: 'Register slash commands', required: true },
{ name: 'chat:write', description: 'Send messages as the bot', required: true },
{ name: 'channels:read', description: 'List channels in workspace', required: false },
{ name: 'users:read', description: 'Access user info for mentions', required: false },
{ name: 'files:write', description: 'Upload files to channels', required: false },
],
credentialFields: [
{ key: 'SLACK_BOT_TOKEN', label: 'Bot User OAuth Token', type: 'password', placeholder: 'xoxb-...', helpText: 'Found in OAuth & Permissions after installing the app' },
{ key: 'SLACK_WEBHOOK_URL', label: 'Incoming Webhook URL', type: 'url', placeholder: 'https://hooks.slack.com/services/T.../B.../...', helpText: 'For posting to a specific channel' },
{ key: 'SLACK_SIGNING_SECRET', label: 'Signing Secret', type: 'password', placeholder: 'abc123...', helpText: 'For verifying incoming requests from Slack' },
{ key: 'SLACK_VERIFICATION_TOKEN', label: 'Verification Token', type: 'password', placeholder: '...', helpText: 'Legacy token for slash command verification' },
],
setupSteps: [
'Go to api.slack.com/apps → Create New App → From scratch',
'Name it "Norma Bot" and select workspace',
'Go to OAuth & Permissions → Add bot token scopes listed above',
'Install app to workspace → Copy Bot User OAuth Token',
'Go to Incoming Webhooks → Toggle ON → Add New Webhook to Workspace',
'Select target channel (e.g. #norma-updates) → Copy webhook URL',
'Go to Slash Commands → Create /civic-status command → Point to /api/slack',
'Go to Basic Information → Copy Signing Secret',
],
useCases: [
'Real-time notifications for draft approvals and grant deadlines',
'Slash commands: /civic-status, /civic-grants, /civic-pipeline',
'Auto-post daily digest of platform activity',
'File sharing for generated proposals and reports',
],
},
{
id: 'canva',
name: 'Canva Connect API',
description: 'Generate branded graphics, social posts, and presentation decks',
Icon: Palette,
color: '#00c4cc',
status: 'not_connected',
consoleUrl: 'https://www.canva.com/developers/',
docsUrl: 'https://www.canva.dev/docs/connect/',
scopes: [
{ name: 'design:content:read', description: 'Read design content', required: true },
{ name: 'design:content:write', description: 'Create and edit designs', required: true },
{ name: 'asset:read', description: 'Read brand assets and templates', required: true },
{ name: 'asset:write', description: 'Upload brand assets', required: false },
{ name: 'brandtemplate:content:read', description: 'Read brand templates', required: true },
{ name: 'folder:read', description: 'Read folder structure', required: false },
],
credentialFields: [
{ key: 'CANVA_CLIENT_ID', label: 'Connect API Client ID', type: 'text', placeholder: 'OC-...' },
{ key: 'CANVA_CLIENT_SECRET', label: 'Connect API Client Secret', type: 'password', placeholder: 'cnva_...' },
{ key: 'CANVA_REDIRECT_URI', label: 'OAuth Redirect URI', type: 'url', placeholder: 'http://45.61.58.125:7400/api/auth/canva/callback' },
{ key: 'CANVA_BRAND_TEMPLATE_ID', label: 'Brand Template ID', type: 'text', placeholder: 'DAF...', helpText: 'Template ID for org-branded social posts' },
],
setupSteps: [
'Go to canva.com/developers → Create an integration',
'Set integration name: "Norma Platform"',
'Select Connect API scopes: design:content:write, asset:read, brandtemplate:content:read',
'Add OAuth redirect URI: http://45.61.58.125:7400/api/auth/canva/callback',
'Submit for review (required for non-test usage)',
'While in dev mode: Add team members as test users',
'Create a brand template in Canva for org social posts',
'Copy template ID from URL and paste below',
],
useCases: [
'Auto-generate social media graphics for petition campaigns',
'Create branded presentation decks for grant proposals',
'Generate infographics from platform data (borrower stats, petition signatures)',
'Produce event banners and email headers from templates',
],
},
{
id: 'clickup',
name: 'ClickUp API',
description: 'Project management, task tracking, and team workflows',
Icon: ClipboardCheck,
color: '#7b68ee',
status: 'not_connected',
consoleUrl: 'https://app.clickup.com/settings/integrations',
docsUrl: 'https://clickup.com/api/',
scopes: [
{ name: 'task:read', description: 'Read tasks and subtasks', required: true },
{ name: 'task:write', description: 'Create and update tasks', required: true },
{ name: 'space:read', description: 'Read workspace structure', required: true },
{ name: 'list:read', description: 'Read lists and folders', required: true },
{ name: 'comment:write', description: 'Add comments to tasks', required: false },
{ name: 'webhook', description: 'Receive task update webhooks', required: false },
],
credentialFields: [
{ key: 'CLICKUP_API_TOKEN', label: 'Personal API Token', type: 'password', placeholder: 'pk_...', helpText: 'Settings → Apps → Generate API Token' },
{ key: 'CLICKUP_TEAM_ID', label: 'Workspace (Team) ID', type: 'text', placeholder: '12345678', helpText: 'Found in workspace URL or via API' },
{ key: 'CLICKUP_SPACE_ID', label: 'Space ID', type: 'text', placeholder: '87654321', helpText: 'The space for org projects' },
{ key: 'CLICKUP_WEBHOOK_SECRET', label: 'Webhook Secret', type: 'password', placeholder: '...', helpText: 'For verifying incoming webhooks' },
],
setupSteps: [
'Go to ClickUp → Settings → Apps (under Integrations)',
'Click "Generate" under Personal API Token',
'Copy the API token (starts with pk_)',
'Find your Team ID: GET https://api.clickup.com/api/v2/team',
'Find your Space ID: GET https://api.clickup.com/api/v2/team/{team_id}/space',
'Create a webhook: POST /api/v2/team/{team_id}/webhook',
'Set webhook URL to http://45.61.58.125:7400/api/webhooks/clickup',
'Paste credentials below and save',
],
useCases: [
'Sync grant pipeline tasks (deadlines, assignments, status)',
'Auto-create tasks when new petition reaches signature threshold',
'Track advocacy campaign milestones across team',
'Generate weekly progress reports from task data',
],
},
{
id: 'gemini',
name: 'Google Gemini AI',
description: 'Powers chat assistant, draft generation, news analysis, and AI tagging',
Icon: Zap,
color: '#4285f4',
status: 'connected',
consoleUrl: 'https://aistudio.google.com/apikey',
docsUrl: 'https://ai.google.dev/gemini-api/docs',
scopes: [
{ name: 'generateContent', description: 'Generate text responses', required: true },
{ name: 'vision', description: 'Analyze images', required: false },
],
credentialFields: [
{ key: 'GEMINI_API_KEY', label: 'Gemini API Key', type: 'password', placeholder: 'AIzaSy...' },
],
setupSteps: [
'Go to aistudio.google.com/apikey',
'Click "Create API Key" → Select or create a Google Cloud project',
'Copy the API key',
'For higher rate limits: Enable billing on the Google Cloud project',
'Paste the key below and save',
],
useCases: [
'Chat assistant (Norma) for platform queries and updates',
'Auto-generate email drafts with configurable tone',
'News article AI analysis and tagging',
'Author resolution from headlines',
],
},
{
id: 'twitter',
name: 'Twitter / X',
description: 'Post updates, track mentions, engage with advocacy community',
Icon: Zap,
color: '#000000',
status: 'not_connected',
consoleUrl: 'https://developer.x.com/en/portal/dashboard',
docsUrl: 'https://developer.x.com/en/docs/twitter-api',
scopes: [
{ name: 'tweet.read', description: 'Read tweets and timelines', required: true },
{ name: 'tweet.write', description: 'Post and delete tweets', required: true },
{ name: 'users.read', description: 'Read user profiles', required: true },
{ name: 'offline.access', description: 'Refresh tokens', required: true },
],
credentialFields: [
{ key: 'TWITTER_API_KEY', label: 'API Key (Consumer Key)', type: 'password', placeholder: 'xxxxxx' },
{ key: 'TWITTER_API_SECRET', label: 'API Secret (Consumer Secret)', type: 'password', placeholder: 'xxxxxx' },
{ key: 'TWITTER_ACCESS_TOKEN', label: 'Access Token', type: 'password', placeholder: 'xxxxxx-xxxxxx' },
{ key: 'TWITTER_ACCESS_SECRET', label: 'Access Token Secret', type: 'password', placeholder: 'xxxxxx' },
],
setupSteps: [
'Go to developer.x.com → Sign up for developer account (Free tier available)',
'Create a new Project and App',
'Set App permissions to "Read and Write"',
'Generate Consumer Keys (API Key + Secret)',
'Generate Access Token and Secret',
'Paste all 4 credentials below',
],
useCases: [
'Auto-post petition milestones and campaign updates',
'Track mentions and engagement metrics',
'Cross-post news articles with commentary',
'Engage with advocacy community',
],
},
{
id: 'bluesky',
name: 'Bluesky',
description: 'Post to Bluesky social network for advocacy reach',
Icon: Zap,
color: '#0085ff',
status: 'not_connected',
consoleUrl: 'https://bsky.app/settings/app-passwords',
docsUrl: 'https://docs.bsky.app/',
scopes: [
{ name: 'atproto', description: 'Full AT Protocol access', required: true },
],
credentialFields: [
{ key: 'BSKY_HANDLE', label: 'Handle', type: 'text', placeholder: 'your-org.bsky.social' },
{ key: 'BSKY_PASSWORD', label: 'App Password', type: 'password', placeholder: 'xxxx-xxxx-xxxx-xxxx', helpText: 'Create an App Password in Bluesky settings (not your login password)' },
],
setupSteps: [
'Go to bsky.app → Log into your org account',
'Go to Settings → App Passwords → Add App Password',
'Name it "Norma Platform"',
'Copy the generated password',
'Paste handle and app password below',
],
useCases: [
'Cross-post advocacy updates to Bluesky',
'Reach decentralized social media audience',
'Share petition links and campaign highlights',
],
},
{
id: 'discord',
name: 'Discord',
description: 'Community server for volunteer coordination and advocacy alerts',
Icon: Zap,
color: '#5865f2',
status: 'not_connected',
consoleUrl: 'https://discord.com/developers/applications',
docsUrl: 'https://discord.com/developers/docs/intro',
scopes: [
{ name: 'bot', description: 'Bot user in server', required: true },
{ name: 'applications.commands', description: 'Slash commands', required: true },
],
credentialFields: [
{ key: 'DISCORD_BOT_TOKEN', label: 'Bot Token', type: 'password', placeholder: 'MTI...' },
{ key: 'DISCORD_GUILD_ID', label: 'Server (Guild) ID', type: 'text', placeholder: '123456789' },
{ key: 'DISCORD_CHANNEL_ID', label: 'Default Channel ID', type: 'text', placeholder: '123456789', helpText: 'Channel for automated posts' },
],
setupSteps: [
'Go to discord.com/developers/applications → New Application',
'Name it "Norma Bot"',
'Go to Bot tab → Click "Add Bot" → Copy Token',
'Enable Message Content Intent under Privileged Gateway Intents',
'Go to OAuth2 → URL Generator → Select "bot" + "applications.commands"',
'Select bot permissions: Send Messages, Embed Links, Read Message History',
'Copy invite URL and add bot to your server',
'Right-click your server name → Copy Server ID',
],
useCases: [
'Post real-time alerts for petition milestones',
'Volunteer coordination in dedicated channels',
'Slash commands for quick data lookups',
'Community engagement notifications',
],
},
{
id: 'reddit',
name: 'Reddit',
description: 'Monitor and engage in advocacy-related subreddits',
Icon: Zap,
color: '#ff4500',
status: 'not_connected',
consoleUrl: 'https://www.reddit.com/prefs/apps',
docsUrl: 'https://www.reddit.com/dev/api/',
scopes: [
{ name: 'read', description: 'Read posts and comments', required: true },
{ name: 'submit', description: 'Submit posts', required: true },
{ name: 'identity', description: 'Access account identity', required: true },
],
credentialFields: [
{ key: 'REDDIT_CLIENT_ID', label: 'Client ID', type: 'text', placeholder: 'xxxxxx', helpText: 'Under the app name in reddit.com/prefs/apps' },
{ key: 'REDDIT_CLIENT_SECRET', label: 'Client Secret', type: 'password', placeholder: 'xxxxxx' },
{ key: 'REDDIT_USERNAME', label: 'Reddit Username', type: 'text', placeholder: 'your_bot_account' },
{ key: 'REDDIT_PASSWORD', label: 'Reddit Password', type: 'password', placeholder: '...' },
],
setupSteps: [
'Go to reddit.com/prefs/apps → Create app',
'Select "script" type',
'Set redirect URI to http://localhost',
'Name it "Norma Advocacy Bot"',
'Copy the client ID (under the app name) and secret',
'Use a dedicated Reddit account for the bot',
],
useCases: [
'Monitor r/studentloans, r/PSLF, r/personalfinance for relevant discussions',
'Share petition links in relevant subreddits',
'Track sentiment and trending topics',
],
},
{
id: 'twitch',
name: 'Twitch',
description: 'Live stream advocacy events and town halls',
Icon: Zap,
color: '#9146ff',
status: 'not_connected',
consoleUrl: 'https://dev.twitch.tv/console/apps',
docsUrl: 'https://dev.twitch.tv/docs/api/',
scopes: [
{ name: 'channel:manage:broadcast', description: 'Manage stream settings', required: true },
{ name: 'chat:read', description: 'Read chat messages', required: true },
{ name: 'chat:edit', description: 'Send chat messages', required: false },
],
credentialFields: [
{ key: 'TWITCH_CLIENT_ID', label: 'Client ID', type: 'text', placeholder: 'xxxxxx' },
{ key: 'TWITCH_CLIENT_SECRET', label: 'Client Secret', type: 'password', placeholder: 'xxxxxx' },
{ key: 'TWITCH_CHANNEL', label: 'Channel Name', type: 'text', placeholder: 'your_channel' },
{ key: 'TWITCH_OAUTH_TOKEN', label: 'OAuth Token', type: 'password', placeholder: 'oauth:xxxxxx' },
],
setupSteps: [
'Go to dev.twitch.tv/console → Register Your Application',
'Set OAuth Redirect URL to http://localhost',
'Category: Chat Bot',
'Copy Client ID and generate a Client Secret',
'Generate OAuth token at twitchtokengenerator.com',
],
useCases: [
'Stream live advocacy town halls and Q&A sessions',
'Chat bot for audience engagement during streams',
'Track viewer metrics for advocacy events',
],
},
];
/* ─── StatusBadge ────────────────────────────────────────────────────────── */
function StatusBadge({ status }: { status: ConnectionStatus }) {
const map = {
connected: { label: 'Connected', color: '#22c55e', bg: 'rgba(34,197,94,0.12)', border: 'rgba(34,197,94,0.3)', Icon: CheckCircle2 },
partial: { label: 'Partial', color: '#f59e0b', bg: 'rgba(245,158,11,0.12)', border: 'rgba(245,158,11,0.3)', Icon: AlertCircle },
not_connected: { label: 'Not Connected', color: '#71717a', bg: 'rgba(113,113,122,0.12)', border: 'rgba(113,113,122,0.3)', Icon: AlertCircle },
error: { label: 'Error', color: '#ef4444', bg: 'rgba(239,68,68,0.12)', border: 'rgba(239,68,68,0.3)', Icon: AlertCircle },
};
const cfg = map[status];
return (
<span className="badge" style={{ color: cfg.color, backgroundColor: cfg.bg, border: `1px solid ${cfg.border}`, gap: 4 }}>
<cfg.Icon size={11} />
{cfg.label}
</span>
);
}
/* ─── CredentialInput ────────────────────────────────────────────────────── */
function CredentialInput({
field,
value,
onChange,
}: {
field: ApiCredentialField;
value: string;
onChange: (val: string) => void;
}) {
const [visible, setVisible] = useState(false);
const inputType = field.type === 'password' && !visible ? 'password' : 'text';
return (
<div>
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>
{field.label}
</label>
<div className="relative">
<Key size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2" style={{ color: 'var(--color-text-muted)' }} />
<input
className="input w-full pl-8 pr-16"
type={inputType}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
style={{ fontSize: '0.8rem', fontFamily: field.type === 'password' ? 'monospace' : undefined }}
/>
<div className="absolute right-1.5 top-1/2 -translate-y-1/2 flex gap-1">
{field.type === 'password' && (
<button
type="button"
onClick={() => setVisible(!visible)}
className="p-1 rounded hover:bg-white/5"
title={visible ? 'Hide' : 'Show'}
style={{ color: 'var(--color-text-muted)' }}
>
{visible ? <EyeOff size={13} /> : <Eye size={13} />}
</button>
)}
{value && (
<button
type="button"
onClick={() => navigator.clipboard.writeText(value)}
className="p-1 rounded hover:bg-white/5"
title="Copy"
style={{ color: 'var(--color-text-muted)' }}
>
<Copy size={13} />
</button>
)}
</div>
</div>
{field.helpText && (
<p className="text-xs mt-0.5" style={{ color: 'var(--color-text-muted)', fontSize: '0.7rem' }}>
{field.helpText}
</p>
)}
</div>
);
}
/* ─── ApiServiceCard ─────────────────────────────────────────────────────── */
function ApiServiceCard({
service,
index,
credentials,
onCredentialChange,
onSave,
onTestConnection,
saving,
}: {
service: ApiService;
index: number;
credentials: Record<string, string>;
onCredentialChange: (serviceId: string, key: string, value: string) => void;
onSave: (serviceId: string) => void;
onTestConnection: (serviceId: string) => void;
saving: string | null;
}) {
const [expanded, setExpanded] = useState(false);
const [activeSection, setActiveSection] = useState<'scopes' | 'setup' | 'credentials' | 'uses'>('credentials');
const { Icon, name, description, color, status, consoleUrl, docsUrl, scopes, credentialFields, setupSteps, useCases } = service;
const filledCount = credentialFields.filter((f) => credentials[f.key]?.trim()).length;
const totalFields = credentialFields.length;
const requiredScopes = scopes.filter((s) => s.required);
const optionalScopes = scopes.filter((s) => !s.required);
return (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, delay: index * 0.05 }}
className="card"
style={{ borderColor: status === 'connected' ? 'rgba(34,197,94,0.3)' : 'var(--color-border)' }}
>
{/* Header row */}
<div
className="flex items-start gap-3 cursor-pointer"
onClick={() => setExpanded(!expanded)}
role="button"
tabIndex={0}
aria-expanded={expanded}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setExpanded(!expanded); } }}
>
<div
style={{
width: 44, height: 44,
borderRadius: 'var(--radius-md)',
backgroundColor: `${color}15`,
border: `1px solid ${color}30`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}
>
<Icon size={22} style={{ color }} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-semibold text-sm" style={{ color: 'var(--color-text)' }}>{name}</h3>
<StatusBadge status={status} />
{filledCount > 0 && (
<span className="text-xs px-1.5 py-0.5 rounded" style={{ backgroundColor: 'var(--color-surface-el)', color: 'var(--color-text-muted)', fontSize: '0.7rem' }}>
{filledCount}/{totalFields} keys set
</span>
)}
</div>
<p className="text-xs mt-0.5" style={{ color: 'var(--color-text-secondary)' }}>{description}</p>
<div className="flex items-center gap-3 mt-1.5">
<a
href={consoleUrl}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="flex items-center gap-1 text-xs hover:underline"
style={{ color: color }}
>
<ExternalLink size={11} /> Console
</a>
<a
href={docsUrl}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="flex items-center gap-1 text-xs hover:underline"
style={{ color: 'var(--color-text-muted)' }}
>
<ExternalLink size={11} /> API Docs
</a>
</div>
</div>
<div style={{ color: 'var(--color-text-muted)', flexShrink: 0, marginTop: 4 }}>
{expanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</div>
</div>
{/* Expanded content */}
<AnimatePresence>
{expanded && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
style={{ overflow: 'hidden' }}
>
{/* Section tabs */}
<div className="flex gap-1 mt-4 mb-3 px-1" role="tablist">
{([
['credentials', 'Credentials', Key],
['scopes', 'Scopes', Shield],
['setup', 'Setup Guide', Info],
['uses', 'Use Cases', Zap],
] as const).map(([key, label, SIcon]) => (
<button
key={key}
role="tab"
aria-selected={activeSection === key}
onClick={(e) => { e.stopPropagation(); setActiveSection(key); }}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium"
style={{
backgroundColor: activeSection === key ? `${color}15` : 'transparent',
color: activeSection === key ? color : 'var(--color-text-muted)',
border: activeSection === key ? `1px solid ${color}30` : '1px solid transparent',
transition: 'all 0.15s',
}}
>
<SIcon size={12} />
{label}
</button>
))}
</div>
{/* Credentials section */}
{activeSection === 'credentials' && (
<div className="px-1">
<div className="grid grid-cols-2 gap-3">
{credentialFields.map((field) => (
<CredentialInput
key={field.key}
field={field}
value={credentials[field.key] || ''}
onChange={(val) => onCredentialChange(service.id, field.key, val)}
/>
))}
</div>
<div className="flex items-center gap-2 mt-4">
<button
type="button"
className="btn btn-primary btn-sm"
onClick={(e) => { e.stopPropagation(); onSave(service.id); }}
disabled={saving === service.id}
style={{ fontSize: '0.75rem' }}
>
{saving === service.id ? (
<><RefreshCw size={12} className="animate-spin" /> Saving...</>
) : (
<><Save size={12} /> Save Credentials</>
)}
</button>
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={(e) => { e.stopPropagation(); onTestConnection(service.id); }}
style={{ fontSize: '0.75rem' }}
>
<Link2 size={12} /> Test Connection
</button>
</div>
</div>
)}
{/* Scopes section */}
{activeSection === 'scopes' && (
<div className="px-1">
<div className="mb-3">
<p className="text-xs font-semibold mb-2" style={{ color: 'var(--color-text)' }}>
Required Scopes ({requiredScopes.length})
</p>
<div className="flex flex-col gap-1.5">
{requiredScopes.map((scope) => (
<div key={scope.name} className="flex items-start gap-2">
<CheckCircle2 size={13} style={{ color: '#22c55e', marginTop: 2, flexShrink: 0 }} />
<div>
<code className="text-xs font-mono" style={{ color: color }}>{scope.name}</code>
<p className="text-xs" style={{ color: 'var(--color-text-muted)', fontSize: '0.7rem' }}>{scope.description}</p>
</div>
</div>
))}
</div>
</div>
{optionalScopes.length > 0 && (
<div>
<p className="text-xs font-semibold mb-2" style={{ color: 'var(--color-text-secondary)' }}>
Optional Scopes ({optionalScopes.length})
</p>
<div className="flex flex-col gap-1.5">
{optionalScopes.map((scope) => (
<div key={scope.name} className="flex items-start gap-2">
<div style={{ width: 13, height: 13, borderRadius: '50%', border: '1px dashed var(--color-text-muted)', marginTop: 2, flexShrink: 0 }} />
<div>
<code className="text-xs font-mono" style={{ color: 'var(--color-text-secondary)' }}>{scope.name}</code>
<p className="text-xs" style={{ color: 'var(--color-text-muted)', fontSize: '0.7rem' }}>{scope.description}</p>
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
{/* Setup guide section */}
{activeSection === 'setup' && (
<div className="px-1">
<ol className="flex flex-col gap-2">
{setupSteps.map((step, i) => (
<li key={i} className="flex items-start gap-2.5 text-xs" style={{ color: 'var(--color-text-secondary)' }}>
<span
className="flex items-center justify-center flex-shrink-0 font-bold"
style={{
width: 20, height: 20, borderRadius: '50%',
backgroundColor: `${color}18`, color: color,
fontSize: '0.65rem', marginTop: 1,
}}
>
{i + 1}
</span>
<span className="leading-relaxed">{step}</span>
</li>
))}
</ol>
</div>
)}
{/* Use cases section */}
{activeSection === 'uses' && (
<div className="px-1">
<div className="flex flex-col gap-2">
{useCases.map((useCase, i) => (
<div key={i} className="flex items-start gap-2 text-xs" style={{ color: 'var(--color-text-secondary)' }}>
<Zap size={12} style={{ color: color, marginTop: 2, flexShrink: 0 }} />
<span className="leading-relaxed">{useCase}</span>
</div>
))}
</div>
</div>
)}
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
}
/* ─── ApisTab ────────────────────────────────────────────────────────────── */
export default function ApisTab() {
const { addToast } = useToast();
const [credentials, setCredentials] = useState<Record<string, Record<string, string>>>({});
const [saving, setSaving] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [services, setServices] = useState(API_SERVICES);
useEffect(() => {
fetchCredentials();
}, []);
async function fetchCredentials() {
try {
const res = await fetch('/api/settings/credentials');
if (res.ok) {
const data = await res.json();
setCredentials(data.credentials || {});
// Update connection status based on which credentials are filled
setServices((prev) =>
prev.map((s) => {
const creds = data.credentials?.[s.id] || {};
const filled = s.credentialFields.filter((f) => creds[f.key]?.trim()).length;
let status: ConnectionStatus = 'not_connected';
if (filled === s.credentialFields.length) status = 'connected';
else if (filled > 0) status = 'partial';
return { ...s, status };
})
);
}
} catch {
// Credentials endpoint may not exist yet — use defaults
} finally {
setLoading(false);
}
}
function handleCredentialChange(serviceId: string, key: string, value: string) {
setCredentials((prev) => ({
...prev,
[serviceId]: { ...(prev[serviceId] || {}), [key]: value },
}));
}
async function handleSave(serviceId: string) {
setSaving(serviceId);
try {
const res = await fetch('/api/settings/credentials', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ serviceId, credentials: credentials[serviceId] || {} }),
});
if (!res.ok) throw new Error('Failed to save');
addToast(`${serviceId} credentials saved`, 'success');
// Update status
const svc = services.find((s) => s.id === serviceId);
if (svc) {
const creds = credentials[serviceId] || {};
const filled = svc.credentialFields.filter((f) => creds[f.key]?.trim()).length;
let status: ConnectionStatus = 'not_connected';
if (filled === svc.credentialFields.length) status = 'connected';
else if (filled > 0) status = 'partial';
setServices((prev) => prev.map((s) => s.id === serviceId ? { ...s, status } : s));
}
} catch {
addToast('Failed to save credentials', 'error');
} finally {
setSaving(null);
}
}
function handleTestConnection(serviceId: string) {
addToast(`Testing ${serviceId} connection... (not yet implemented)`, 'info');
}
const connectedCount = services.filter((s) => s.status === 'connected').length;
const partialCount = services.filter((s) => s.status === 'partial').length;
const totalCount = services.length;
return (
<div className="p-4 sm:p-6 max-w-4xl mx-auto">
{/* Header */}
<div className="mb-6">
<div className="flex items-center gap-2.5 mb-1">
<div
style={{
width: 32, height: 32,
borderRadius: 'var(--radius-md)',
background: 'linear-gradient(135deg, rgba(37,99,235,0.2), rgba(124,58,237,0.2))',
border: '1px solid rgba(37,99,235,0.3)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}
>
<Key size={16} style={{ color: '#60a5fa' }} />
</div>
<div>
<h1 className="text-lg font-semibold" style={{ color: 'var(--color-text)' }}>
API Connections
</h1>
<p className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
{connectedCount} connected, {partialCount} partial, {totalCount - connectedCount - partialCount} pending
</p>
</div>
</div>
</div>
{/* Summary bar */}
<div
className="rounded-xl px-4 py-3 mb-5"
style={{
background: connectedCount === totalCount
? 'rgba(34,197,94,0.06)'
: 'rgba(37,99,235,0.04)',
border: `1px solid ${connectedCount === totalCount ? 'rgba(34,197,94,0.3)' : 'rgba(37,99,235,0.15)'}`,
}}
>
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-6">
{services.map((s) => (
<div key={s.id} className="flex items-center gap-1.5" title={s.name}>
<div
style={{
width: 8, height: 8, borderRadius: '50%',
backgroundColor: s.status === 'connected' ? '#22c55e' : s.status === 'partial' ? '#f59e0b' : '#71717a',
boxShadow: s.status === 'connected' ? '0 0 6px rgba(34,197,94,0.5)' : undefined,
}}
/>
<span className="text-xs" style={{ color: 'var(--color-text-muted)' }}>{s.name.split(' ')[0]}</span>
</div>
))}
</div>
<div className="flex items-center gap-2">
<div
style={{ width: 80, height: 6, borderRadius: 3, backgroundColor: 'var(--color-surface-el)', overflow: 'hidden' }}
role="meter"
aria-valuenow={connectedCount}
aria-valuemin={0}
aria-valuemax={totalCount}
>
<div style={{
width: `${(connectedCount / totalCount) * 100}%`,
height: '100%', borderRadius: 3,
backgroundColor: connectedCount === totalCount ? '#22c55e' : '#2563eb',
transition: 'width 0.3s',
}} />
</div>
<span className="text-xs font-medium" style={{ color: 'var(--color-text-muted)' }}>
{connectedCount}/{totalCount}
</span>
</div>
</div>
</div>
{/* Service cards */}
<div className="flex flex-col gap-3" role="list" aria-label="API services">
{services.map((service, i) => (
<ApiServiceCard
key={service.id}
service={service}
index={i}
credentials={credentials[service.id] || {}}
onCredentialChange={handleCredentialChange}
onSave={handleSave}
onTestConnection={handleTestConnection}
saving={saving}
/>
))}
</div>
{/* Bottom info */}
<div
className="mt-6 rounded-xl px-4 py-3"
style={{ backgroundColor: 'var(--color-surface)', border: '1px dashed var(--color-border)' }}
>
<div className="flex items-start gap-2">
<Shield size={14} style={{ color: 'var(--color-text-muted)', marginTop: 2, flexShrink: 0 }} />
<p className="text-xs leading-relaxed" style={{ color: 'var(--color-text-muted)' }}>
Credentials are stored encrypted in the database and never exposed in client-side code.
OAuth tokens can be revoked at any time from each service's console.
All API calls are logged in the audit trail.
</p>
</div>
</div>
</div>
);
}