Discord
This commit is contained in:
@@ -59,6 +59,7 @@ export function App() {
|
||||
<Route path="/projects/new" element={<Dashboard.NewProjectRedirect />} />
|
||||
<Route path="/projects/:id" element={<Dashboard.ProjectScreen />} />
|
||||
<Route path="/email" element={<Dashboard.EmailScreen />} />
|
||||
<Route path="/browser" element={<Dashboard.BrowserScreen />} />
|
||||
<Route path="/terminal" element={<Dashboard.TerminalScreen />} />
|
||||
<Route path="/auth/signout" element={<Authentication.SignoutScreen />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import type { LayoutNode, PanelComponents } from 'officerdev';
|
||||
import { WorkspaceView } from 'officerdev';
|
||||
import { useWorkspacesState } from 'state/useWorkspacesState';
|
||||
import { useIsMobile } from 'hooks/useIsMobile';
|
||||
import { useGlobal } from 'hooks/useGlobal';
|
||||
import { defaultLayout } from './defaultLayout';
|
||||
import { TabList } from './TabList';
|
||||
import { TabPreview } from './TabPreview';
|
||||
|
||||
export const BrowserScreen = () => {
|
||||
const isMobile = useIsMobile();
|
||||
const [selectedId, setSelectedId] = useGlobal<string | null>('BROWSER_SELECTED_TAB', null);
|
||||
const workspace = useWorkspacesState<LayoutNode>('screens/browser', defaultLayout);
|
||||
|
||||
const components: PanelComponents = useMemo(
|
||||
() => ({
|
||||
'browser-tabs': TabList,
|
||||
'browser-preview': TabPreview,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const mobilePanelId = isMobile && selectedId ? 'browser-preview' : undefined;
|
||||
const onMobileBack = useCallback(() => setSelectedId(null), [setSelectedId]);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full pt-2">
|
||||
<WorkspaceView
|
||||
workspace={workspace}
|
||||
locked
|
||||
components={components}
|
||||
mobilePanelId={mobilePanelId}
|
||||
onMobilePanelChange={mobilePanelId ? () => onMobileBack() : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,177 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Globe, Copy, Check, Loader2, X, Eye } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useGlobal } from 'hooks/useGlobal';
|
||||
|
||||
type RelayToken = {
|
||||
token: string;
|
||||
port: number;
|
||||
};
|
||||
|
||||
type Target = {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
type: string;
|
||||
title: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export const TabList = () => {
|
||||
const client = useClient();
|
||||
const [selectedId, setSelectedId] = useGlobal<string | null>('BROWSER_SELECTED_TAB', null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const { data: status } = useQuery({
|
||||
queryKey: ['browser-status'],
|
||||
queryFn: () => client.get<{ extensionConnected: boolean; targetCount: number }>('/browser/status'),
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
const { data: tokenData } = useQuery({
|
||||
queryKey: ['browser-relay-token'],
|
||||
queryFn: () => client.get<RelayToken>('/browser/relay-token'),
|
||||
});
|
||||
|
||||
const { data: targets, isLoading } = useQuery({
|
||||
queryKey: ['browser-targets'],
|
||||
queryFn: () => client.get<Target[]>('/browser/targets'),
|
||||
refetchInterval: 3000,
|
||||
enabled: !!status?.extensionConnected,
|
||||
});
|
||||
|
||||
const targetList: Target[] = targets ?? [];
|
||||
|
||||
const handleCopyToken = async () => {
|
||||
if (!tokenData?.token) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(tokenData.token);
|
||||
setCopied(true);
|
||||
toast.success('Token copied to clipboard');
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
toast.error('Failed to copy');
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = async (targetId: string) => {
|
||||
try {
|
||||
await client.post(`/browser/targets/${targetId}/close`);
|
||||
} catch {
|
||||
toast.error('Failed to close tab');
|
||||
}
|
||||
};
|
||||
|
||||
const handleActivate = async (targetId: string) => {
|
||||
try {
|
||||
await client.post(`/browser/targets/${targetId}/activate`);
|
||||
} catch {
|
||||
toast.error('Failed to activate tab');
|
||||
}
|
||||
};
|
||||
|
||||
const isConnected = status?.extensionConnected ?? false;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-y-auto">
|
||||
<div className="flex items-center gap-2 border-b px-3 py-2">
|
||||
<Globe className="h-4 w-4 opacity-60" />
|
||||
<span className="text-sm font-medium">Browser</span>
|
||||
<div
|
||||
className={`ml-auto h-2 w-2 rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'}`}
|
||||
title={isConnected ? 'Extension connected' : 'Extension not connected'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isConnected && (
|
||||
<div className="flex flex-col gap-3 p-4 text-sm">
|
||||
<p className="opacity-60">Connect the Officer Browser Relay extension to start.</p>
|
||||
|
||||
{tokenData && (
|
||||
<div className="flex flex-col gap-2 rounded-lg border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium opacity-60">Server address</span>
|
||||
<code className="text-xs">{window.location.hostname}</code>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium opacity-60">Relay port</span>
|
||||
<code className="text-xs">{tokenData.port}</code>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium opacity-60">Relay token</span>
|
||||
<Button variant="ghost" size="sm" className="ml-auto h-6 px-2" onClick={handleCopyToken}>
|
||||
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
|
||||
<span className="ml-1 text-xs">{copied ? 'Copied' : 'Copy'}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ol className="list-inside list-decimal space-y-1 text-xs opacity-50">
|
||||
<li>Load the extension from <code>src/extensions/browser-relay/</code></li>
|
||||
<li>Open extension options</li>
|
||||
<li>Enter the server address, port, and token</li>
|
||||
<li>Click the extension icon on a tab</li>
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isConnected && isLoading && (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<Loader2 className="h-5 w-5 animate-spin opacity-50" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isConnected && !isLoading && targetList.length === 0 && (
|
||||
<div className="flex h-32 flex-col items-center justify-center gap-2 text-sm opacity-50">
|
||||
<Globe className="h-6 w-6" />
|
||||
<span>No tabs attached</span>
|
||||
<span className="text-xs">Click the extension icon on a tab</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isConnected && targetList.length > 0 && (
|
||||
<div className="flex flex-1 flex-col divide-y divide-white/10">
|
||||
{targetList.map((target) => (
|
||||
<button
|
||||
key={target.id}
|
||||
onClick={() => setSelectedId(target.id)}
|
||||
className={`group flex flex-col gap-0.5 px-3 py-2.5 text-left transition-colors cursor-pointer ${
|
||||
selectedId === target.id ? 'bg-accent' : 'hover:bg-accent/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">{target.title || 'Untitled'}</span>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<button
|
||||
onClick={(ev) => {
|
||||
ev.stopPropagation();
|
||||
handleActivate(target.id);
|
||||
}}
|
||||
className="rounded p-0.5 hover:bg-black/10"
|
||||
title="Focus tab"
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(ev) => {
|
||||
ev.stopPropagation();
|
||||
handleClose(target.id);
|
||||
}}
|
||||
className="rounded p-0.5 hover:bg-black/10"
|
||||
title="Close tab"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<span className="truncate text-xs opacity-50">{target.url}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Globe, RefreshCw, Send, Terminal, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useGlobal } from 'hooks/useGlobal';
|
||||
|
||||
export const TabPreview = () => {
|
||||
const client = useClient();
|
||||
const [selectedId] = useGlobal<string | null>('BROWSER_SELECTED_TAB', null);
|
||||
const [navUrl, setNavUrl] = useState('');
|
||||
const [evalExpr, setEvalExpr] = useState('');
|
||||
const [evalResult, setEvalResult] = useState<string | null>(null);
|
||||
const [isNavigating, setIsNavigating] = useState(false);
|
||||
const [isEvaluating, setIsEvaluating] = useState(false);
|
||||
|
||||
const {
|
||||
data: screenshot,
|
||||
isLoading,
|
||||
refetch,
|
||||
isFetching,
|
||||
} = useQuery({
|
||||
queryKey: ['browser-screenshot', selectedId],
|
||||
queryFn: () => client.get<{ data: string; format: string }>(`/browser/targets/${selectedId}/screenshot`),
|
||||
enabled: !!selectedId,
|
||||
refetchInterval: 10000,
|
||||
});
|
||||
|
||||
const handleNavigate = useCallback(async () => {
|
||||
if (!selectedId || !navUrl.trim()) return;
|
||||
setIsNavigating(true);
|
||||
try {
|
||||
await client.post(`/browser/targets/${selectedId}/navigate`, { url: navUrl.trim() });
|
||||
setTimeout(() => void refetch(), 1000);
|
||||
} catch {
|
||||
toast.error('Navigation failed');
|
||||
} finally {
|
||||
setIsNavigating(false);
|
||||
}
|
||||
}, [client, selectedId, navUrl, refetch]);
|
||||
|
||||
const handleEvaluate = useCallback(async () => {
|
||||
if (!selectedId || !evalExpr.trim()) return;
|
||||
setIsEvaluating(true);
|
||||
try {
|
||||
const res = await client.post<{ result: unknown }>(`/browser/targets/${selectedId}/evaluate`, {
|
||||
expression: evalExpr.trim(),
|
||||
});
|
||||
setEvalResult(JSON.stringify(res.result, null, 2));
|
||||
} catch (err) {
|
||||
setEvalResult(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
} finally {
|
||||
setIsEvaluating(false);
|
||||
}
|
||||
}, [client, selectedId, evalExpr]);
|
||||
|
||||
if (!selectedId) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 text-sm opacity-50">
|
||||
<Globe className="h-8 w-8" />
|
||||
<span>Select a tab to preview</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
{/* URL bar */}
|
||||
<div className="flex items-center gap-2 border-b px-3 py-2">
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 shrink-0" onClick={() => void refetch()} disabled={isFetching}>
|
||||
{isFetching ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
<form
|
||||
className="flex flex-1 items-center gap-2"
|
||||
onSubmit={(ev) => {
|
||||
ev.preventDefault();
|
||||
void handleNavigate();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={navUrl}
|
||||
onChange={(ev) => setNavUrl(ev.target.value)}
|
||||
placeholder="Navigate to URL..."
|
||||
className="flex-1 rounded-md border bg-transparent px-2 py-1 text-sm outline-none focus:border-cyan-500"
|
||||
/>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 shrink-0" type="submit" disabled={isNavigating || !navUrl.trim()}>
|
||||
<Send className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Screenshot */}
|
||||
<div className="flex-1 overflow-auto p-2">
|
||||
{isLoading && !screenshot && (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin opacity-50" />
|
||||
</div>
|
||||
)}
|
||||
{screenshot && (
|
||||
<img
|
||||
src={`data:image/${screenshot.format};base64,${screenshot.data}`}
|
||||
alt="Tab screenshot"
|
||||
className="w-full rounded-md border"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* JS Console */}
|
||||
<div className="border-t">
|
||||
<form
|
||||
className="flex items-center gap-2 px-3 py-2"
|
||||
onSubmit={(ev) => {
|
||||
ev.preventDefault();
|
||||
void handleEvaluate();
|
||||
}}
|
||||
>
|
||||
<Terminal className="h-3.5 w-3.5 shrink-0 opacity-50" />
|
||||
<input
|
||||
type="text"
|
||||
value={evalExpr}
|
||||
onChange={(ev) => setEvalExpr(ev.target.value)}
|
||||
placeholder="Evaluate JavaScript..."
|
||||
className="flex-1 bg-transparent text-sm outline-none font-mono"
|
||||
/>
|
||||
<Button variant="ghost" size="sm" className="h-6 px-2 shrink-0" type="submit" disabled={isEvaluating || !evalExpr.trim()}>
|
||||
{isEvaluating ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Run'}
|
||||
</Button>
|
||||
</form>
|
||||
{evalResult !== null && (
|
||||
<pre className="max-h-32 overflow-auto border-t px-3 py-2 text-xs font-mono opacity-70">{evalResult}</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
|
||||
export const defaultLayout: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'browser-root',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'browser-tabs', appType: null }, size: 25 },
|
||||
{ node: { type: 'panel', id: 'browser-preview', appType: null }, size: 75 },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { BrowserScreen } from './BrowserScreen';
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { ChevronLeft, ChevronRight, Loader2, Mail, Paperclip, RefreshCw } from 'lucide-react';
|
||||
import { ChevronLeft, ChevronRight, Inbox, Loader2, Mail, Paperclip, RefreshCw, Send, ShieldAlert, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
@@ -18,6 +18,13 @@ type GoogleStatus = {
|
||||
|
||||
const LIMIT = 50;
|
||||
|
||||
const FOLDERS = [
|
||||
{ key: 'inbox', label: 'Inbox', icon: Inbox },
|
||||
{ key: 'sent', label: 'Sent', icon: Send },
|
||||
{ key: 'spam', label: 'Spam', icon: ShieldAlert },
|
||||
{ key: 'trash', label: 'Trash', icon: Trash2 },
|
||||
] as const;
|
||||
|
||||
const formatDate = (iso: string) => {
|
||||
const date = new Date(iso);
|
||||
const now = new Date();
|
||||
@@ -32,6 +39,7 @@ export const EmailList = () => {
|
||||
const client = useClient();
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedId, setSelectedId] = useGlobal<string | null>('EMAIL_SELECTED', null);
|
||||
const [folder, setFolder] = useGlobal<string>('EMAIL_FOLDER', 'inbox');
|
||||
const [page, setPage] = useState(1);
|
||||
const { jobs, createJob } = useJobs({ type: 'gmail-sync' });
|
||||
const isSyncing = jobs.some((j) => j.status === 'queued' || j.status === 'running');
|
||||
@@ -42,11 +50,16 @@ export const EmailList = () => {
|
||||
});
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['email-messages', page],
|
||||
queryKey: ['email-messages', page, folder],
|
||||
queryFn: () =>
|
||||
client.get<{ messages: EmailSummary[]; total: number }>(`/email/messages?page=${page}&limit=${LIMIT}`),
|
||||
client.get<{ messages: EmailSummary[]; total: number }>(`/email/messages?page=${page}&limit=${LIMIT}&folder=${folder}`),
|
||||
});
|
||||
|
||||
const handleFolderChange = (newFolder: string) => {
|
||||
setFolder(newFolder);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleSync = async () => {
|
||||
try {
|
||||
await createJob({ lane: 'google-api', type: 'gmail-sync', notify: false });
|
||||
@@ -106,8 +119,25 @@ export const EmailList = () => {
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-y-auto">
|
||||
<div className="flex items-center gap-2 border-b px-3 py-2">
|
||||
<Mail className="h-4 w-4 opacity-60" />
|
||||
<span className="text-sm font-medium">Inbox</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{FOLDERS.map((f) => {
|
||||
const Icon = f.icon;
|
||||
const isActive = folder === f.key;
|
||||
return (
|
||||
<button
|
||||
key={f.key}
|
||||
onClick={() => handleFolderChange(f.key)}
|
||||
className={`flex items-center gap-1 rounded px-2 py-1 text-xs transition-colors cursor-pointer ${
|
||||
isActive ? 'bg-accent font-medium' : 'opacity-60 hover:opacity-100 hover:bg-accent/50'
|
||||
}`}
|
||||
title={f.label}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">{f.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<span className="text-xs opacity-50">{total}</span>
|
||||
<button
|
||||
onClick={handleSync}
|
||||
|
||||
@@ -110,7 +110,7 @@ export const Dock = ({ items, className }: DockProps) => {
|
||||
};
|
||||
|
||||
|
||||
import { Home, MessageCircle, FileText, FolderOpen, Code, LayoutGrid, Bot, ScrollText, FolderKanban, Monitor, Mail } from 'lucide-react';
|
||||
import { Home, MessageCircle, FileText, FolderOpen, Code, LayoutGrid, Bot, ScrollText, FolderKanban, Monitor, Mail, Globe } from 'lucide-react';
|
||||
|
||||
export const ALL_DOCK_ITEMS: DockItem[] = [
|
||||
{ label: 'Home', to: '/', icon: Home, color: '#f59e0b' },
|
||||
@@ -123,6 +123,7 @@ export const ALL_DOCK_ITEMS: DockItem[] = [
|
||||
{ label: 'Logs', to: '/task-logs', icon: ScrollText, color: '#94a3b8' },
|
||||
{ label: 'Terminal', to: '/terminal', icon: Monitor, color: '#f97316' },
|
||||
{ label: 'Projects', to: '/projects', icon: FolderKanban, color: '#10b981' },
|
||||
{ label: 'Browser', to: '/browser', icon: Globe, color: '#06b6d4' },
|
||||
{ label: 'Workspaces', to: '/workspaces', icon: LayoutGrid, color: '#8b5cf6' },
|
||||
];
|
||||
|
||||
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Copy } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
|
||||
type DiscordConnection = {
|
||||
linked: boolean;
|
||||
discordId?: string;
|
||||
};
|
||||
|
||||
type DiscordStatus = {
|
||||
configured: boolean;
|
||||
running: boolean;
|
||||
serverInvite: string | null;
|
||||
botHandle: string | null;
|
||||
};
|
||||
|
||||
export const DiscordAccount = () => {
|
||||
const client = useClient();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [connection, setConnection] = useState<DiscordConnection>({ linked: false });
|
||||
const [botStatus, setBotStatus] = useState<DiscordStatus>({ configured: false, running: false });
|
||||
const [pairingCode, setPairingCode] = useState<string | null>(null);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
client.get<DiscordConnection>('/channels/discord/connection').then(setConnection),
|
||||
client.get<DiscordStatus>('/channels/discord/status').then(setBotStatus),
|
||||
])
|
||||
.catch(() => {})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleGenerateCode = async () => {
|
||||
setIsGenerating(true);
|
||||
try {
|
||||
const res = await client.post<{ code: string; expiresIn: number }>('/channels/discord/pair', {});
|
||||
setPairingCode(res.code);
|
||||
} catch {
|
||||
toast.error('Failed to generate pairing code');
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyCode = () => {
|
||||
if (pairingCode) {
|
||||
navigator.clipboard.writeText(pairingCode);
|
||||
toast.success('Code copied to clipboard');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnect = async () => {
|
||||
try {
|
||||
await client.delete('/channels/discord/connection');
|
||||
setConnection({ linked: false });
|
||||
setPairingCode(null);
|
||||
toast.success('Discord account unlinked');
|
||||
} catch {
|
||||
toast.error('Failed to unlink Discord account');
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) return null;
|
||||
|
||||
if (!botStatus.configured) {
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<p className="text-sm text-duck-dark/60 dark:text-foreground/60">
|
||||
Discord integration has not been configured yet. Ask your administrator to set up the Discord bot in the
|
||||
Enterprise settings.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const connectionInfo = (botStatus.serverInvite || botStatus.botHandle) && (
|
||||
<div className="rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4 grid gap-2">
|
||||
{botStatus.serverInvite && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-duck-dark/50 dark:text-foreground/50 shrink-0">Server:</span>
|
||||
<a
|
||||
href={botStatus.serverInvite}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-duck-teal underline truncate"
|
||||
>
|
||||
{botStatus.serverInvite}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{botStatus.botHandle && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-duck-dark/50 dark:text-foreground/50 shrink-0">Bot:</span>
|
||||
<code className="text-xs bg-duck-dark/5 dark:bg-foreground/5 px-2 py-1 rounded">{botStatus.botHandle}</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (connection.linked) {
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<div className="flex items-center gap-3 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4">
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-green-500 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-duck-dark dark:text-foreground">Linked</p>
|
||||
<p className="text-xs text-duck-dark/50 dark:text-foreground/50 truncate">
|
||||
Discord ID: {connection.discordId}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{connectionInfo}
|
||||
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">
|
||||
You can send direct messages to the bot on Discord and they will be handled by your PI agent.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleDisconnect}
|
||||
className="w-full h-11 cursor-pointer"
|
||||
>
|
||||
Unlink Discord
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<p className="text-sm text-duck-dark/60 dark:text-foreground/60">
|
||||
Link your Discord account to chat with your PI agent via direct messages.
|
||||
</p>
|
||||
|
||||
{connectionInfo}
|
||||
|
||||
{pairingCode ? (
|
||||
<div className="grid gap-3">
|
||||
<div className="flex items-center justify-between rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4">
|
||||
<code className="text-2xl font-mono font-bold tracking-[0.3em] text-duck-dark dark:text-foreground">
|
||||
{pairingCode}
|
||||
</code>
|
||||
<Button type="button" variant="ghost" size="icon" onClick={handleCopyCode} className="cursor-pointer shrink-0">
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-duck-dark/50 dark:text-foreground/50">
|
||||
Send this code as a direct message to the bot on Discord. Expires in 10 minutes.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleGenerateCode}
|
||||
disabled={isGenerating}
|
||||
className="w-full h-11 cursor-pointer"
|
||||
>
|
||||
Generate New Code
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleGenerateCode}
|
||||
disabled={isGenerating}
|
||||
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold transition-all duration-200 hover:scale-105 cursor-pointer"
|
||||
>
|
||||
{isGenerating ? 'Generating...' : 'Link Discord'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+237
@@ -0,0 +1,237 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
|
||||
type DiscordConfig = {
|
||||
configured: boolean;
|
||||
enabled: boolean;
|
||||
botToken: string | null;
|
||||
serverInvite: string | null;
|
||||
botHandle: string | null;
|
||||
};
|
||||
|
||||
type DiscordStatus = {
|
||||
configured: boolean;
|
||||
enabled: boolean;
|
||||
running: boolean;
|
||||
botUsername: string | null;
|
||||
};
|
||||
|
||||
const SetupGuide = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<CollapsibleTrigger className="flex items-center gap-2 text-sm font-medium text-duck-teal cursor-pointer hover:underline w-full">
|
||||
<ChevronDown className={`h-3.5 w-3.5 transition-transform duration-200 ${open ? 'rotate-180' : ''}`} />
|
||||
Step-by-step setup guide
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<ol className="mt-3 grid gap-4 text-sm text-duck-dark/70 dark:text-foreground/70 list-decimal list-outside pl-5">
|
||||
<li>
|
||||
<strong className="text-duck-dark dark:text-foreground">Create a Discord application</strong>
|
||||
<p className="mt-1">
|
||||
Go to the{' '}
|
||||
<a href="https://discord.com/developers/applications" target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
|
||||
Discord Developer Portal
|
||||
</a>{' '}
|
||||
and click <strong>New Application</strong>.
|
||||
</p>
|
||||
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
|
||||
<li>Fill in <strong>Name</strong> (e.g. "Officer"), optionally a <strong>Description</strong> and <strong>Tags</strong></li>
|
||||
<li>Leave everything else empty — <strong>Interactions Endpoint URL</strong>, <strong>Linked Roles Verification URL</strong>, and <strong>Terms of Service / Privacy Policy URLs</strong> are not needed</li>
|
||||
</ul>
|
||||
<p className="mt-1">Click <strong>Create</strong>.</p>
|
||||
<p className="mt-2 text-xs text-duck-dark/50 dark:text-foreground/50">
|
||||
You will see an Application ID and Public Key on this page — you can ignore both. Our bot connects via the gateway (WebSocket), not webhooks, so these are not used.
|
||||
</p>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<strong className="text-duck-dark dark:text-foreground">Go to the Bot page</strong>
|
||||
<p className="mt-1">
|
||||
In the left sidebar, click <strong>Bot</strong>. A bot user is created automatically with your application.
|
||||
You can optionally set a custom username and avatar here — this is what users will see when they DM the bot.
|
||||
</p>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<strong className="text-duck-dark dark:text-foreground">Enable privileged intents</strong>
|
||||
<p className="mt-1">
|
||||
Still on the <strong>Bot</strong> page, scroll down to <strong>Privileged Gateway Intents</strong> and enable:
|
||||
</p>
|
||||
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
|
||||
<li><strong>Presence Intent</strong> — shows the bot as online in your server</li>
|
||||
<li><strong>Server Members Intent</strong> — makes the bot visible in the member list so users can find and DM it</li>
|
||||
<li><strong>Message Content Intent</strong> — required to read DM message text</li>
|
||||
</ul>
|
||||
<p className="mt-1">Click <strong>Save Changes</strong>.</p>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<strong className="text-duck-dark dark:text-foreground">Copy the bot token</strong>
|
||||
<p className="mt-1">
|
||||
On the <strong>Bot</strong> page, click <strong>Reset Token</strong> (or <strong>View Token</strong> if this is a new bot).
|
||||
Copy the token — you will only see it once.
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-duck-dark/50 dark:text-foreground/50">
|
||||
Keep this token secret. Anyone with this token can control your bot. If it leaks, reset it immediately from this page.
|
||||
</p>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<strong className="text-duck-dark dark:text-foreground">Invite the bot to your server (optional)</strong>
|
||||
<p className="mt-1">
|
||||
Open this URL in your browser, replacing{' '}
|
||||
<code className="text-xs bg-duck-dark/5 dark:bg-foreground/5 px-1.5 py-0.5 rounded">YOUR_APP_ID</code>{' '}
|
||||
with the Application ID from the <strong>General Information</strong> page:
|
||||
</p>
|
||||
<code className="mt-1.5 block text-xs bg-duck-dark/5 dark:bg-foreground/5 px-3 py-2 rounded break-all">
|
||||
https://discord.com/oauth2/authorize?client_id=YOUR_APP_ID&scope=bot&permissions=0
|
||||
</code>
|
||||
<p className="mt-1.5">
|
||||
Select your server and click <strong>Authorize</strong>. No bot permissions are needed — leave them at zero.
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-duck-dark/50 dark:text-foreground/50">
|
||||
Adding the bot to a shared server is optional but has a few advantages:
|
||||
</p>
|
||||
<ul className="mt-1 text-xs text-duck-dark/50 dark:text-foreground/50 list-disc list-outside pl-5 grid gap-0.5">
|
||||
<li>Users can find the bot in the member list and right-click → <strong>Message</strong> to start a DM</li>
|
||||
<li>The bot shows as online in the server, so users can see at a glance whether it's running</li>
|
||||
<li>Easier onboarding — you can pin the pairing instructions in a channel for your team</li>
|
||||
</ul>
|
||||
<p className="mt-1 text-xs text-duck-dark/50 dark:text-foreground/50">
|
||||
Without a shared server, users can still DM the bot by searching its exact username — but a shared server makes discovery much simpler.
|
||||
</p>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<strong className="text-duck-dark dark:text-foreground">Paste the token below and save</strong>
|
||||
<p className="mt-1">
|
||||
Paste the bot token into the field below and click <strong>Save</strong>.
|
||||
The bot will come online automatically. Users can then link their Discord accounts from their personal integration settings.
|
||||
</p>
|
||||
</li>
|
||||
</ol>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
|
||||
export const DiscordBotConfig = () => {
|
||||
const client = useClient();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [botToken, setBotToken] = useState('');
|
||||
const [serverInvite, setServerInvite] = useState('');
|
||||
const [botHandle, setBotHandle] = useState('');
|
||||
const [status, setStatus] = useState<DiscordStatus | null>(null);
|
||||
|
||||
const fetchStatus = () => {
|
||||
client
|
||||
.get<DiscordStatus>('/channels/discord/status')
|
||||
.then(setStatus)
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
client.get<DiscordConfig>('/channels/discord/config').then((data) => {
|
||||
if (data.botToken) setBotToken(data.botToken);
|
||||
if (data.serverInvite) setServerInvite(data.serverInvite);
|
||||
if (data.botHandle) setBotHandle(data.botHandle);
|
||||
}),
|
||||
client.get<DiscordStatus>('/channels/discord/status').then(setStatus),
|
||||
])
|
||||
.catch(() => {})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (isSaving) return;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await client.put('/channels/discord/config', {
|
||||
botToken: botToken.trim(),
|
||||
serverInvite: serverInvite.trim() || undefined,
|
||||
botHandle: botHandle.trim() || undefined,
|
||||
});
|
||||
toast.success('Discord bot configuration saved');
|
||||
fetchStatus();
|
||||
} catch {
|
||||
toast.error('Failed to save Discord bot configuration');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) return null;
|
||||
|
||||
return (
|
||||
<div className="grid gap-5">
|
||||
{status && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3">
|
||||
<div className={`h-2.5 w-2.5 rounded-full shrink-0 ${status.running ? 'bg-green-500' : 'bg-duck-dark/20 dark:bg-foreground/20'}`} />
|
||||
<span className="text-sm text-duck-dark dark:text-foreground">
|
||||
{status.running ? `Online as ${status.botUsername}` : status.configured ? 'Bot configured but not running' : 'Not configured'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SetupGuide />
|
||||
|
||||
<div className="border-t border-duck-dark/10 dark:border-foreground/10 pt-5 grid gap-5">
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">Bot Token</span>
|
||||
<Input
|
||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="password"
|
||||
value={botToken}
|
||||
onChange={(ev) => setBotToken(ev.target.value)}
|
||||
placeholder="MTIz..."
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">Server Invite Link</span>
|
||||
<Input
|
||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
value={serverInvite}
|
||||
onChange={(ev) => setServerInvite(ev.target.value)}
|
||||
placeholder="https://discord.gg/..."
|
||||
/>
|
||||
<span className="text-xs text-duck-dark/40 dark:text-foreground/40">
|
||||
Shown to users so they can join the server and find the bot.
|
||||
</span>
|
||||
</Label>
|
||||
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">Bot Handle</span>
|
||||
<Input
|
||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
value={botHandle}
|
||||
onChange={(ev) => setBotHandle(ev.target.value)}
|
||||
placeholder="my-bot#1234"
|
||||
/>
|
||||
<span className="text-xs text-duck-dark/40 dark:text-foreground/40">
|
||||
The bot's username#discriminator — shown to users who want to DM the bot directly.
|
||||
</span>
|
||||
</Label>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || !botToken.trim()}
|
||||
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100"
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Puzzle, KeyRound, UserCircle } from 'lucide-react';
|
||||
import { Puzzle, KeyRound, UserCircle, MessageCircle } from 'lucide-react';
|
||||
import type { LayoutNode, PanelComponents } from 'officerdev';
|
||||
import { WorkspaceLayout } from 'officerdev';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
@@ -9,16 +9,20 @@ import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { SettingsSidebar, SettingsContent, type SettingsSection } from '../SettingsPanel';
|
||||
import { GoogleOAuthConfig } from './GoogleOAuthConfig';
|
||||
import { GoogleAccount } from './GoogleAccount';
|
||||
import { DiscordBotConfig } from './DiscordBotConfig';
|
||||
import { DiscordAccount } from './DiscordAccount';
|
||||
|
||||
const GLOBAL_KEY = 'INTEGRATIONS_SETTINGS_SELECTED';
|
||||
const TAB_KEY = 'INTEGRATIONS_SETTINGS_TAB';
|
||||
|
||||
const enterpriseSections: SettingsSection[] = [
|
||||
{ key: 'google-oauth', icon: KeyRound, title: 'Google OAuth', description: 'Client ID and secret for Google APIs', content: <GoogleOAuthConfig /> },
|
||||
{ key: 'discord-bot', icon: MessageCircle, title: 'Discord Bot', description: 'Bot token for Discord integration', content: <DiscordBotConfig /> },
|
||||
];
|
||||
|
||||
const personalSections: SettingsSection[] = [
|
||||
{ key: 'google-account', icon: UserCircle, title: 'Google Account', description: 'Connect your Google account', content: <GoogleAccount /> },
|
||||
{ key: 'discord-account', icon: MessageCircle, title: 'Discord', description: 'Link your Discord account', content: <DiscordAccount /> },
|
||||
];
|
||||
|
||||
const IntegrationsSidebar = () => {
|
||||
|
||||
@@ -17,3 +17,4 @@ export * from './Workspaces';
|
||||
export * from './Projects';
|
||||
export * from './Terminal';
|
||||
export * from './Email';
|
||||
export * from './Browser';
|
||||
|
||||
Reference in New Issue
Block a user