feat(settings): add Google Workspace integration card
Phase 1 of Data Sovereignty Zone implementation: - Add Google Workspace section to Settings > Integrations tab - Show connection status, import counts (emails, files, photos, events) - Connect/Disconnect Google account buttons - "Open Data Browser" button (Phase 2 will implement the browser) - Add getStoredCounts() and getInstance() to GoogleDataService Privacy messaging: "Your data is encrypted with AES-256 and stored only in your browser. Choose what to share to the board." 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
8bc3924a10
commit
c9c8c008b2
|
|
@ -256,6 +256,51 @@ export class GoogleDataService {
|
||||||
return await checkStorageQuota();
|
return await checkStorageQuota();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get count of items stored for each service
|
||||||
|
async getStoredCounts(): Promise<Record<GoogleService, number>> {
|
||||||
|
const counts: Record<GoogleService, number> = {
|
||||||
|
gmail: 0,
|
||||||
|
drive: 0,
|
||||||
|
photos: 0,
|
||||||
|
calendar: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const db = await openDatabase();
|
||||||
|
if (!db) return counts;
|
||||||
|
|
||||||
|
// Count items in each store
|
||||||
|
const countStore = async (storeName: string): Promise<number> => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
try {
|
||||||
|
const tx = db.transaction(storeName, 'readonly');
|
||||||
|
const store = tx.objectStore(storeName);
|
||||||
|
const countRequest = store.count();
|
||||||
|
countRequest.onsuccess = () => resolve(countRequest.result);
|
||||||
|
countRequest.onerror = () => resolve(0);
|
||||||
|
} catch {
|
||||||
|
resolve(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
counts.gmail = await countStore('gmail');
|
||||||
|
counts.drive = await countStore('drive');
|
||||||
|
counts.photos = await countStore('photos');
|
||||||
|
counts.calendar = await countStore('calendar');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to get stored counts:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton getter
|
||||||
|
static getInstance(): GoogleDataService {
|
||||||
|
return getGoogleDataService();
|
||||||
|
}
|
||||||
|
|
||||||
// Schedule periodic touch for Safari
|
// Schedule periodic touch for Safari
|
||||||
private scheduleTouchInterval(): void {
|
private scheduleTouchInterval(): void {
|
||||||
// Touch data every 6 hours to prevent 7-day eviction
|
// Touch data every 6 hours to prevent 7-day eviction
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useDialogs } from "tldraw"
|
||||||
import { SettingsDialog } from "./SettingsDialog"
|
import { SettingsDialog } from "./SettingsDialog"
|
||||||
import { getFathomApiKey, saveFathomApiKey, removeFathomApiKey, isFathomApiKeyConfigured } from "../lib/fathomApiKey"
|
import { getFathomApiKey, saveFathomApiKey, removeFathomApiKey, isFathomApiKeyConfigured } from "../lib/fathomApiKey"
|
||||||
import { linkEmailToAccount, checkEmailStatus, type LookupResult } from "../lib/auth/cryptidEmailService"
|
import { linkEmailToAccount, checkEmailStatus, type LookupResult } from "../lib/auth/cryptidEmailService"
|
||||||
|
import { GoogleDataService, type GoogleService } from "../lib/google"
|
||||||
|
|
||||||
// AI tool model configurations
|
// AI tool model configurations
|
||||||
const AI_TOOLS = [
|
const AI_TOOLS = [
|
||||||
|
|
@ -144,6 +145,17 @@ export function UserSettingsModal({ onClose, isDarkMode, onToggleDarkMode }: Use
|
||||||
const [emailLinkLoading, setEmailLinkLoading] = useState(false)
|
const [emailLinkLoading, setEmailLinkLoading] = useState(false)
|
||||||
const [emailLinkMessage, setEmailLinkMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
|
const [emailLinkMessage, setEmailLinkMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
|
||||||
|
|
||||||
|
// Google Data state
|
||||||
|
const [googleConnected, setGoogleConnected] = useState(false)
|
||||||
|
const [googleLoading, setGoogleLoading] = useState(false)
|
||||||
|
const [googleCounts, setGoogleCounts] = useState<Record<GoogleService, number>>({
|
||||||
|
gmail: 0,
|
||||||
|
drive: 0,
|
||||||
|
photos: 0,
|
||||||
|
calendar: 0,
|
||||||
|
})
|
||||||
|
const [showGoogleDataBrowser, setShowGoogleDataBrowser] = useState(false)
|
||||||
|
|
||||||
// Check API key status
|
// Check API key status
|
||||||
const checkApiKeys = () => {
|
const checkApiKeys = () => {
|
||||||
const settings = localStorage.getItem("openai_api_key")
|
const settings = localStorage.getItem("openai_api_key")
|
||||||
|
|
@ -191,6 +203,27 @@ export function UserSettingsModal({ onClose, isDarkMode, onToggleDarkMode }: Use
|
||||||
fetchEmailStatus()
|
fetchEmailStatus()
|
||||||
}, [session.authed, session.username])
|
}, [session.authed, session.username])
|
||||||
|
|
||||||
|
// Check Google connection status when modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
const checkGoogleStatus = async () => {
|
||||||
|
try {
|
||||||
|
const service = GoogleDataService.getInstance()
|
||||||
|
const isAuthed = await service.isAuthenticated()
|
||||||
|
setGoogleConnected(isAuthed)
|
||||||
|
|
||||||
|
if (isAuthed) {
|
||||||
|
// Get stored item counts
|
||||||
|
const counts = await service.getStoredCounts()
|
||||||
|
setGoogleCounts(counts)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to check Google status:', error)
|
||||||
|
setGoogleConnected(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
checkGoogleStatus()
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Handle email linking
|
// Handle email linking
|
||||||
const handleLinkEmail = async () => {
|
const handleLinkEmail = async () => {
|
||||||
if (!emailInput.trim() || !session.username) return
|
if (!emailInput.trim() || !session.username) return
|
||||||
|
|
@ -238,6 +271,39 @@ export function UserSettingsModal({ onClose, isDarkMode, onToggleDarkMode }: Use
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle Google connect
|
||||||
|
const handleGoogleConnect = async () => {
|
||||||
|
setGoogleLoading(true)
|
||||||
|
try {
|
||||||
|
const service = GoogleDataService.getInstance()
|
||||||
|
// Request all services by default
|
||||||
|
await service.authenticate(['gmail', 'drive', 'photos', 'calendar'])
|
||||||
|
setGoogleConnected(true)
|
||||||
|
// Refresh counts after connection
|
||||||
|
const counts = await service.getStoredCounts()
|
||||||
|
setGoogleCounts(counts)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Google connect failed:', error)
|
||||||
|
} finally {
|
||||||
|
setGoogleLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Google disconnect
|
||||||
|
const handleGoogleDisconnect = async () => {
|
||||||
|
try {
|
||||||
|
const service = GoogleDataService.getInstance()
|
||||||
|
await service.signOut()
|
||||||
|
setGoogleConnected(false)
|
||||||
|
setGoogleCounts({ gmail: 0, drive: 0, photos: 0, calendar: 0 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Google disconnect failed:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate total imported items
|
||||||
|
const totalGoogleItems = Object.values(googleCounts).reduce((a, b) => a + b, 0)
|
||||||
|
|
||||||
// Handle escape key and click outside
|
// Handle escape key and click outside
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleEscape = (e: KeyboardEvent) => {
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
|
|
@ -767,10 +833,142 @@ export function UserSettingsModal({ onClose, isDarkMode, onToggleDarkMode }: Use
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-divider" />
|
||||||
|
|
||||||
|
{/* Data Import Section */}
|
||||||
|
<h3 style={{ fontSize: '14px', fontWeight: '600', marginBottom: '12px', marginTop: '8px', color: colors.text }}>
|
||||||
|
Data Import
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Google Workspace */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '12px',
|
||||||
|
backgroundColor: colors.cardBg,
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: `1px solid ${colors.cardBorder}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}>
|
||||||
|
<span style={{ fontSize: '20px' }}>🔐</span>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<span style={{ fontSize: '13px', fontWeight: '600', color: colors.textHeading }}>Google Workspace</span>
|
||||||
|
<p style={{ fontSize: '11px', color: colors.textMuted, marginTop: '2px' }}>
|
||||||
|
Import Gmail, Drive, Photos & Calendar - encrypted locally
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className={`status-badge ${googleConnected ? 'success' : 'warning'}`} style={{ fontSize: '10px' }}>
|
||||||
|
{googleConnected ? 'Connected' : 'Not Connected'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{googleConnected && totalGoogleItems > 0 && (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: '6px',
|
||||||
|
marginBottom: '12px',
|
||||||
|
padding: '8px',
|
||||||
|
backgroundColor: isDarkMode ? 'rgba(99, 102, 241, 0.1)' : 'rgba(99, 102, 241, 0.05)',
|
||||||
|
borderRadius: '6px',
|
||||||
|
}}>
|
||||||
|
{googleCounts.gmail > 0 && (
|
||||||
|
<span style={{
|
||||||
|
fontSize: '10px',
|
||||||
|
padding: '3px 8px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
backgroundColor: colors.localBg,
|
||||||
|
color: colors.localText,
|
||||||
|
fontWeight: '500',
|
||||||
|
}}>
|
||||||
|
📧 {googleCounts.gmail} emails
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{googleCounts.drive > 0 && (
|
||||||
|
<span style={{
|
||||||
|
fontSize: '10px',
|
||||||
|
padding: '3px 8px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
backgroundColor: colors.gpuBg,
|
||||||
|
color: colors.gpuText,
|
||||||
|
fontWeight: '500',
|
||||||
|
}}>
|
||||||
|
📁 {googleCounts.drive} files
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{googleCounts.photos > 0 && (
|
||||||
|
<span style={{
|
||||||
|
fontSize: '10px',
|
||||||
|
padding: '3px 8px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
backgroundColor: colors.cloudBg,
|
||||||
|
color: colors.cloudText,
|
||||||
|
fontWeight: '500',
|
||||||
|
}}>
|
||||||
|
📷 {googleCounts.photos} photos
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{googleCounts.calendar > 0 && (
|
||||||
|
<span style={{
|
||||||
|
fontSize: '10px',
|
||||||
|
padding: '3px 8px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
backgroundColor: colors.successBg,
|
||||||
|
color: colors.successText,
|
||||||
|
fontWeight: '500',
|
||||||
|
}}>
|
||||||
|
📅 {googleCounts.calendar} events
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p style={{ fontSize: '11px', color: colors.textMuted, marginBottom: '12px', lineHeight: '1.4' }}>
|
||||||
|
Your data is encrypted with AES-256 and stored only in your browser.
|
||||||
|
Choose what to share to the board.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
|
{googleConnected ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="settings-action-btn"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
onClick={() => setShowGoogleDataBrowser(true)}
|
||||||
|
disabled={totalGoogleItems === 0}
|
||||||
|
>
|
||||||
|
Open Data Browser
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="settings-action-btn secondary"
|
||||||
|
onClick={handleGoogleDisconnect}
|
||||||
|
>
|
||||||
|
Disconnect
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="settings-action-btn"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
onClick={handleGoogleConnect}
|
||||||
|
disabled={googleLoading}
|
||||||
|
>
|
||||||
|
{googleLoading ? 'Connecting...' : 'Connect Google Account'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{googleConnected && totalGoogleItems === 0 && (
|
||||||
|
<p style={{ fontSize: '11px', color: colors.warningText, marginTop: '8px', textAlign: 'center' }}>
|
||||||
|
No data imported yet. Visit <a href="/google" style={{ color: colors.linkColor }}>/google</a> to import.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Future Integrations Placeholder */}
|
{/* Future Integrations Placeholder */}
|
||||||
<div style={{ marginTop: '16px', padding: '12px', backgroundColor: colors.legendBg, borderRadius: '6px', border: `1px dashed ${colors.cardBorder}` }}>
|
<div style={{ marginTop: '16px', padding: '12px', backgroundColor: colors.legendBg, borderRadius: '6px', border: `1px dashed ${colors.cardBorder}` }}>
|
||||||
<p style={{ fontSize: '12px', color: colors.textMuted, textAlign: 'center' }}>
|
<p style={{ fontSize: '12px', color: colors.textMuted, textAlign: 'center' }}>
|
||||||
More integrations coming soon: Google Calendar, Notion, and more
|
More integrations coming soon: Notion, Slack, and more
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue