Spaces:
Configuration error
Configuration error
| "use client"; | |
| import { useCallback, useEffect, useMemo, useState } from 'react'; | |
| export type Settings = { | |
| timezone: string; | |
| }; | |
| export function useSettings() { | |
| const [settings, setSettings] = useState<Settings>({ timezone: '' }); | |
| useEffect(() => { | |
| try { | |
| const timezone = localStorage.getItem('user_timezone') || ''; | |
| setSettings({ timezone }); | |
| } catch {} | |
| }, []); | |
| const persist = useCallback((s: Settings) => { | |
| setSettings(s); | |
| try { | |
| localStorage.setItem('user_timezone', s.timezone); | |
| } catch {} | |
| }, []); | |
| return { settings, setSettings: persist } as const; | |
| } | |
| function coerceEmailFrom(value: unknown): string | null { | |
| if (typeof value === 'string' && value.includes('@')) { | |
| return value; | |
| } | |
| if (value && typeof value === 'object') { | |
| const candidate = | |
| (value as any).emailAddress ?? | |
| (value as any).email ?? | |
| (value as any).value ?? | |
| (value as any).address; | |
| if (typeof candidate === 'string' && candidate.includes('@')) { | |
| return candidate; | |
| } | |
| } | |
| return null; | |
| } | |
| function deriveEmailFromPayload(payload: any): string { | |
| if (!payload) return ''; | |
| const profileSlice = payload?.profile; | |
| const candidateObjects: any[] = []; | |
| if (profileSlice && typeof profileSlice === 'object') { | |
| candidateObjects.push(profileSlice); | |
| if ((profileSlice as any).response_data && typeof (profileSlice as any).response_data === 'object') { | |
| candidateObjects.push((profileSlice as any).response_data); | |
| } | |
| if (Array.isArray((profileSlice as any).items)) { | |
| for (const entry of (profileSlice as any).items as any[]) { | |
| if (entry && typeof entry === 'object') { | |
| if (typeof entry.data === 'object') candidateObjects.push(entry.data); | |
| if (typeof entry.response_data === 'object') candidateObjects.push(entry.response_data); | |
| if (typeof entry.profile === 'object') candidateObjects.push(entry.profile); | |
| } | |
| } | |
| } | |
| } | |
| const directCandidates = [payload?.email]; | |
| for (const obj of candidateObjects) { | |
| if (!obj || typeof obj !== 'object') continue; | |
| directCandidates.push( | |
| obj?.email, | |
| obj?.email_address, | |
| obj?.emailAddress, | |
| obj?.profile?.email, | |
| obj?.profile?.emailAddress, | |
| obj?.profile?.email_address, | |
| obj?.user?.email, | |
| obj?.user?.emailAddress, | |
| obj?.user?.email_address, | |
| obj?.data?.email, | |
| obj?.data?.emailAddress, | |
| obj?.data?.email_address, | |
| ); | |
| const emailAddresses = (obj as any).emailAddresses; | |
| if (Array.isArray(emailAddresses)) { | |
| for (const entry of emailAddresses) { | |
| const email = coerceEmailFrom(entry) ?? coerceEmailFrom((entry as any)?.value); | |
| if (email) return email; | |
| } | |
| } | |
| } | |
| for (const candidate of directCandidates) { | |
| const email = coerceEmailFrom(candidate); | |
| if (email) return email; | |
| } | |
| return ''; | |
| } | |
| export default function SettingsModal({ | |
| open, | |
| onClose, | |
| settings, | |
| onSave, | |
| }: { | |
| open: boolean; | |
| onClose: () => void; | |
| settings: Settings; | |
| onSave: (s: Settings) => void; | |
| }) { | |
| const [timezone, setTimezone] = useState(settings.timezone); | |
| const [connectingGmail, setConnectingGmail] = useState(false); | |
| const [isRefreshingGmail, setIsRefreshingGmail] = useState(false); | |
| const [isDisconnecting, setIsDisconnecting] = useState(false); | |
| const [gmailStatusMessage, setGmailStatusMessage] = useState(''); | |
| const [gmailConnected, setGmailConnected] = useState(false); | |
| const [gmailEmail, setGmailEmail] = useState(''); | |
| const [gmailConnId, setGmailConnId] = useState(''); | |
| const [gmailProfile, setGmailProfile] = useState<Record<string, unknown> | null>(null); | |
| const readStoredUserId = useCallback(() => { | |
| if (typeof window === 'undefined') return ''; | |
| try { | |
| return localStorage.getItem('openpoke_user_id') || ''; | |
| } catch { | |
| return ''; | |
| } | |
| }, []); | |
| const ensureUserId = useCallback(() => { | |
| if (typeof window === 'undefined') { | |
| return `web-${Math.random().toString(36).slice(2)}`; | |
| } | |
| try { | |
| const existing = localStorage.getItem('openpoke_user_id'); | |
| if (existing) return existing; | |
| const cryptoObj = (globalThis as { crypto?: Crypto }).crypto; | |
| const randomPart = | |
| cryptoObj && typeof cryptoObj.randomUUID === 'function' | |
| ? cryptoObj.randomUUID().replace(/-/g, '') | |
| : Math.random().toString(36).slice(2); | |
| const generated = `web-${randomPart}`; | |
| localStorage.setItem('openpoke_user_id', generated); | |
| return generated; | |
| } catch { | |
| return `web-${Math.random().toString(36).slice(2)}`; | |
| } | |
| }, []); | |
| const readStoredConnectionRequestId = useCallback(() => { | |
| if (gmailConnId) return gmailConnId; | |
| if (typeof window === 'undefined') return ''; | |
| try { | |
| return localStorage.getItem('gmail_connection_request_id') || ''; | |
| } catch { | |
| return ''; | |
| } | |
| }, [gmailConnId]); | |
| useEffect(() => { | |
| try { | |
| const savedConnected = localStorage.getItem('gmail_connected') === 'true'; | |
| const savedConnId = localStorage.getItem('gmail_connection_request_id') || ''; | |
| const savedEmail = localStorage.getItem('gmail_email') || ''; | |
| setGmailConnected(savedConnected); | |
| setGmailConnId(savedConnId); | |
| setGmailEmail(savedEmail); | |
| if (savedConnected && savedEmail) { | |
| setGmailStatusMessage(`Connected as ${savedEmail}`); | |
| } | |
| } catch {} | |
| }, []); | |
| const gmailProfileDetails = useMemo(() => { | |
| if (!gmailProfile) return [] as { label: string; value: string }[]; | |
| const details: { label: string; value: string }[] = []; | |
| const messagesTotal = (gmailProfile as any)?.messagesTotal; | |
| if (typeof messagesTotal === 'number') { | |
| details.push({ label: 'Messages', value: messagesTotal.toLocaleString() }); | |
| } | |
| const threadsTotal = (gmailProfile as any)?.threadsTotal; | |
| if (typeof threadsTotal === 'number') { | |
| details.push({ label: 'Threads', value: threadsTotal.toLocaleString() }); | |
| } | |
| const historyId = (gmailProfile as any)?.historyId ?? (gmailProfile as any)?.historyID; | |
| if (historyId !== undefined && historyId !== null && historyId !== '') { | |
| details.push({ label: 'History ID', value: String(historyId) }); | |
| } | |
| return details; | |
| }, [gmailProfile]); | |
| const handleConnectGmail = useCallback(async () => { | |
| try { | |
| setConnectingGmail(true); | |
| setGmailStatusMessage(''); | |
| const userId = ensureUserId(); | |
| const resp = await fetch('/api/gmail/connect', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ userId }), | |
| }); | |
| const data = await resp.json().catch(() => ({})); | |
| if (!resp.ok || !data?.ok) { | |
| const msg = data?.error || `Failed (${resp.status})`; | |
| setGmailStatusMessage(msg); | |
| return; | |
| } | |
| const url = data?.redirect_url; | |
| const connId = data?.connection_request_id || ''; | |
| if (connId) { | |
| setGmailConnId(connId); | |
| try { | |
| localStorage.setItem('gmail_connection_request_id', connId); | |
| } catch {} | |
| } | |
| setGmailConnected(false); | |
| setGmailEmail(''); | |
| setGmailProfile(null); | |
| if (url) { | |
| window.open(url, '_blank', 'noopener'); | |
| setGmailStatusMessage('Gmail authorization opened in a new tab. Complete it, then press “Refresh status”.'); | |
| } else { | |
| setGmailStatusMessage('Connection initiated. Refresh status once authorization completes.'); | |
| } | |
| } catch (e: any) { | |
| setGmailStatusMessage(e?.message || 'Failed to connect Gmail'); | |
| } finally { | |
| setConnectingGmail(false); | |
| } | |
| }, [ensureUserId]); | |
| const refreshGmailStatus = useCallback(async () => { | |
| const userId = readStoredUserId(); | |
| const connectionRequestId = readStoredConnectionRequestId(); | |
| if (!userId && !connectionRequestId) { | |
| setGmailConnected(false); | |
| setGmailProfile(null); | |
| setGmailEmail(''); | |
| setGmailStatusMessage('Connect Gmail to get started.'); | |
| return; | |
| } | |
| try { | |
| setIsRefreshingGmail(true); | |
| setGmailStatusMessage('Refreshing Gmail status…'); | |
| const resp = await fetch('/api/gmail/status', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ userId, connectionRequestId }), | |
| }); | |
| const data = await resp.json().catch(() => ({})); | |
| if (!resp.ok || !data?.ok) { | |
| const message = data?.error || `Failed (${resp.status})`; | |
| setGmailConnected(false); | |
| setGmailProfile(null); | |
| setGmailEmail(''); | |
| setGmailStatusMessage(message); | |
| return; | |
| } | |
| if (!gmailConnId && connectionRequestId) { | |
| setGmailConnId(connectionRequestId); | |
| } | |
| const profileData = data?.profile && typeof data.profile === 'object' ? (data.profile as Record<string, unknown>) : null; | |
| setGmailProfile(profileData); | |
| const derivedEmail = deriveEmailFromPayload({ email: data?.email, profile: profileData }); | |
| const email = derivedEmail || (typeof data?.email === 'string' ? data.email : ''); | |
| const connected = Boolean(data?.connected); | |
| setGmailConnected(connected); | |
| setGmailEmail(email); | |
| if (connected) { | |
| const source = typeof data?.profile_source === 'string' ? data.profile_source : ''; | |
| const sourceNote = source === 'fetched' ? 'Verified moments ago.' : source === 'cache' ? 'Loaded from cache.' : ''; | |
| const message = email ? `Connected as ${email}` : 'Gmail connected.'; | |
| setGmailStatusMessage(sourceNote ? `${message} ${sourceNote}` : message); | |
| try { | |
| localStorage.setItem('gmail_connected', 'true'); | |
| if (email) localStorage.setItem('gmail_email', email); | |
| if (typeof data?.user_id === 'string' && data.user_id) { | |
| localStorage.setItem('openpoke_user_id', data.user_id); | |
| } | |
| } catch {} | |
| } else { | |
| const statusText = typeof data?.status === 'string' && data.status && data.status !== 'UNKNOWN' | |
| ? `Status: ${data.status}` | |
| : 'Not connected yet.'; | |
| setGmailStatusMessage(statusText); | |
| try { | |
| localStorage.removeItem('gmail_connected'); | |
| localStorage.removeItem('gmail_email'); | |
| } catch {} | |
| } | |
| } catch (e: any) { | |
| setGmailConnected(false); | |
| setGmailProfile(null); | |
| setGmailEmail(''); | |
| setGmailStatusMessage(e?.message || 'Failed to check Gmail status'); | |
| } finally { | |
| setIsRefreshingGmail(false); | |
| } | |
| }, [gmailConnId, readStoredConnectionRequestId, readStoredUserId]); | |
| const handleDisconnectGmail = useCallback(async () => { | |
| if (typeof window !== 'undefined') { | |
| const proceed = window.confirm('Disconnect Gmail from OpenPoke?'); | |
| if (!proceed) return; | |
| } | |
| try { | |
| setIsDisconnecting(true); | |
| setGmailStatusMessage('Disconnecting Gmail…'); | |
| const userId = readStoredUserId(); | |
| const connectionRequestId = readStoredConnectionRequestId(); | |
| const resp = await fetch('/api/gmail/disconnect', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ userId, connectionRequestId }), | |
| }); | |
| const data = await resp.json().catch(() => ({})); | |
| if (!resp.ok || !data?.ok) { | |
| const message = data?.error || `Failed (${resp.status})`; | |
| setGmailStatusMessage(message); | |
| return; | |
| } | |
| setGmailConnected(false); | |
| setGmailEmail(''); | |
| setGmailProfile(null); | |
| setGmailConnId(''); | |
| setGmailStatusMessage('Gmail disconnected.'); | |
| try { | |
| localStorage.removeItem('gmail_connected'); | |
| localStorage.removeItem('gmail_email'); | |
| localStorage.removeItem('gmail_connection_request_id'); | |
| localStorage.removeItem('openpoke_user_id'); | |
| } catch {} | |
| } catch (e: any) { | |
| setGmailStatusMessage(e?.message || 'Failed to disconnect Gmail'); | |
| } finally { | |
| setIsDisconnecting(false); | |
| } | |
| }, [readStoredConnectionRequestId, readStoredUserId]); | |
| useEffect(() => { | |
| setTimezone(settings.timezone); | |
| }, [settings]); | |
| useEffect(() => { | |
| if (!open) return; | |
| void refreshGmailStatus(); | |
| }, [open, refreshGmailStatus]); | |
| if (!open) return null; | |
| const connectButtonLabel = connectingGmail ? 'Opening…' : gmailConnected ? 'Reconnect' : 'Connect Gmail'; | |
| const refreshButtonLabel = isRefreshingGmail ? 'Refreshing…' : 'Refresh status'; | |
| const disconnectButtonLabel = isDisconnecting ? 'Disconnecting…' : 'Disconnect'; | |
| return ( | |
| <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 p-4"> | |
| <div className="card w-full max-w-lg p-6"> | |
| <div className="mb-4 flex items-center justify-between"> | |
| <h2 className="text-lg font-semibold">Settings</h2> | |
| <button onClick={onClose} className="rounded-md p-2 hover:bg-gray-100" aria-label="Close settings"> | |
| ✕ | |
| </button> | |
| </div> | |
| <div className="space-y-4"> | |
| <div> | |
| <label className="mb-1 block text-sm font-medium text-gray-700">Timezone</label> | |
| <input | |
| className="input" | |
| type="text" | |
| value={timezone} | |
| onChange={(e) => setTimezone(e.target.value)} | |
| placeholder="e.g. America/New_York, Europe/London" | |
| readOnly={!timezone} | |
| /> | |
| <p className="mt-1 text-xs text-gray-500"> | |
| {timezone ? 'Auto-detected from browser. Edit to override.' : 'Will be auto-detected on next page load.'} | |
| </p> | |
| </div> | |
| <div className="pt-2"> | |
| <div className="mb-1 text-sm font-medium text-gray-700">Integrations</div> | |
| <div className="rounded-xl border border-gray-200 bg-white/70 p-4 shadow-sm"> | |
| <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"> | |
| <div> | |
| <div className="text-sm font-semibold text-gray-900">Gmail (via Composio)</div> | |
| <p className="mt-1 text-sm text-gray-600"> | |
| Connect Gmail to unlock email search, drafting, and automations inside OpenPoke. | |
| </p> | |
| </div> | |
| <span | |
| className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-medium ring-1 ring-inset ${ | |
| gmailConnected | |
| ? 'bg-emerald-50 text-emerald-700 ring-emerald-200' | |
| : 'bg-amber-50 text-amber-700 ring-amber-200' | |
| }`} | |
| > | |
| {gmailConnected ? 'Connected' : 'Not connected'} | |
| </span> | |
| </div> | |
| {gmailConnected ? ( | |
| <div className="mt-4 space-y-3 rounded-lg border border-gray-100 bg-gray-50 p-3"> | |
| <div> | |
| <div className="text-[11px] uppercase tracking-wide text-gray-500">Connected account</div> | |
| <div className="mt-1 text-sm font-medium text-gray-900">{gmailEmail || 'Email unavailable'}</div> | |
| </div> | |
| {gmailProfileDetails.length > 0 && ( | |
| <dl className="grid grid-cols-1 gap-2 text-xs text-gray-600 sm:grid-cols-3"> | |
| {gmailProfileDetails.map((item) => ( | |
| <div key={item.label} className="rounded-md border border-gray-200 bg-white/80 p-2"> | |
| <dt className="text-[10px] font-semibold uppercase tracking-wide text-gray-500">{item.label}</dt> | |
| <dd className="mt-1 text-sm font-medium text-gray-900">{item.value}</dd> | |
| </div> | |
| ))} | |
| </dl> | |
| )} | |
| {gmailStatusMessage && ( | |
| <p className="text-xs text-gray-500" aria-live="polite">{gmailStatusMessage}</p> | |
| )} | |
| </div> | |
| ) : ( | |
| <div className="mt-4 rounded-lg border border-dashed border-gray-200 p-3 text-sm text-gray-500" aria-live="polite"> | |
| {gmailStatusMessage || 'Complete the connection to view your Gmail account details here.'} | |
| </div> | |
| )} | |
| <div className="mt-4 flex flex-wrap gap-2"> | |
| <button | |
| type="button" | |
| className="btn" | |
| onClick={handleConnectGmail} | |
| disabled={connectingGmail || isRefreshingGmail || isDisconnecting} | |
| aria-busy={connectingGmail} | |
| > | |
| {connectButtonLabel} | |
| </button> | |
| <button | |
| type="button" | |
| className="rounded-md border border-gray-200 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-60" | |
| onClick={refreshGmailStatus} | |
| disabled={isRefreshingGmail || connectingGmail} | |
| aria-busy={isRefreshingGmail} | |
| > | |
| {refreshButtonLabel} | |
| </button> | |
| {gmailConnected && ( | |
| <button | |
| type="button" | |
| className="rounded-md border border-transparent bg-red-50 px-3 py-2 text-sm font-medium text-red-600 hover:bg-red-100 disabled:cursor-not-allowed disabled:opacity-60" | |
| onClick={handleDisconnectGmail} | |
| disabled={isDisconnecting || connectingGmail} | |
| aria-busy={isDisconnecting} | |
| > | |
| {disconnectButtonLabel} | |
| </button> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div className="mt-6 flex justify-end gap-2"> | |
| <button onClick={onClose} className="rounded-md px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Cancel</button> | |
| <button | |
| className="btn" | |
| onClick={() => { | |
| onSave({ timezone }); | |
| onClose(); | |
| }} | |
| > | |
| Save | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |