mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-20 22:01:45 +00:00
feat(mcp): granular OAuth scopes and per-client rate limiting
- Split `media:read` into `geo:read` and `weather:read` scopes - Add dedicated `atlas:read/write` scopes (previously under `places`) - Add dedicated `todos:read/write` scopes (previously under `collab`) - Rate limiting now keyed by userId+clientId instead of userId alone - Bind MCP sessions to the OAuth client that created them - Log MCP tool calls to audit log with clientId - Invalidate all MCP sessions on addon state change - Reduce session sweep interval from 10min to 1min - Update all translations with new scope labels
This commit is contained in:
@@ -370,6 +370,11 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
const [showEmoji, setShowEmoji] = useState(false)
|
||||
const [reactMenu, setReactMenu] = useState(null) // { msgId, x, y }
|
||||
const [deletingIds, setDeletingIds] = useState(new Set())
|
||||
const deleteTimersRef = useRef<ReturnType<typeof setTimeout>[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
return () => { deleteTimersRef.current.forEach(clearTimeout) }
|
||||
}, [])
|
||||
|
||||
const containerRef = useRef(null)
|
||||
const messagesRef = useRef(messages)
|
||||
@@ -483,13 +488,14 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
requestAnimationFrame(() => {
|
||||
setDeletingIds(prev => new Set(prev).add(msgId))
|
||||
})
|
||||
setTimeout(async () => {
|
||||
const t = setTimeout(async () => {
|
||||
try {
|
||||
await collabApi.deleteMessage(tripId, msgId)
|
||||
setMessages(prev => prev.map(m => m.id === msgId ? { ...m, _deleted: true } : m))
|
||||
} catch {}
|
||||
setDeletingIds(prev => { const s = new Set(prev); s.delete(msgId); return s })
|
||||
}, 400)
|
||||
deleteTimersRef.current.push(t)
|
||||
}, [tripId])
|
||||
|
||||
const handleReact = useCallback(async (msgId, emoji) => {
|
||||
|
||||
@@ -16,12 +16,13 @@ function formatTime(timeStr, is12h) {
|
||||
}
|
||||
|
||||
function formatDayLabel(date, t, locale) {
|
||||
const d = new Date(date + 'T00:00:00')
|
||||
const now = new Date()
|
||||
const tomorrow = new Date(); tomorrow.setDate(now.getDate() + 1)
|
||||
const nowDate = now.toISOString().split('T')[0]
|
||||
const tomorrowUtc = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1))
|
||||
const tomorrowDate = tomorrowUtc.toISOString().split('T')[0]
|
||||
|
||||
if (d.toDateString() === now.toDateString()) return t('collab.whatsNext.today') || 'Today'
|
||||
if (d.toDateString() === tomorrow.toDateString()) return t('collab.whatsNext.tomorrow') || 'Tomorrow'
|
||||
if (date === nowDate) return t('collab.whatsNext.today') || 'Today'
|
||||
if (date === tomorrowDate) return t('collab.whatsNext.tomorrow') || 'Tomorrow'
|
||||
|
||||
return new Date(date + 'T00:00:00Z').toLocaleDateString(locale || undefined, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||
}
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import React, { useState } from 'react'
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react'
|
||||
import { getScopesByGroup } from '../../api/oauthScopes'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
interface Props {
|
||||
selected: string[]
|
||||
onChange: (scopes: string[]) => void
|
||||
}
|
||||
|
||||
const scopesByGroup = getScopesByGroup()
|
||||
|
||||
export default function ScopeGroupPicker({ selected, onChange }: Props): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState<Record<string, boolean>>({})
|
||||
|
||||
const scopesByGroup = getScopesByGroup(t)
|
||||
const allScopeKeys = Object.values(scopesByGroup).flat().map(s => s.scope)
|
||||
const allSelected = allScopeKeys.every(s => selected.includes(s))
|
||||
|
||||
@@ -23,7 +24,7 @@ export default function ScopeGroupPicker({ selected, onChange }: Props): React.R
|
||||
onClick={() => onChange(allSelected ? [] : allScopeKeys)}
|
||||
className="text-xs px-2 py-0.5 rounded border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
{allSelected ? 'Deselect all' : 'Select all'}
|
||||
{allSelected ? t('settings.oauth.modal.deselectAll') : t('settings.oauth.modal.selectAll')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-1 max-h-96 overflow-y-auto pr-1">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Section from './Section'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Trash2, Copy, Terminal, Plus, Check, KeyRound, ChevronDown, ChevronRight, RefreshCw } from 'lucide-react'
|
||||
@@ -131,6 +131,11 @@ export default function IntegrationsTab(): React.ReactElement {
|
||||
const [mcpCreating, setMcpCreating] = useState(false)
|
||||
const [mcpDeleteId, setMcpDeleteId] = useState<number | null>(null)
|
||||
const [copiedKey, setCopiedKey] = useState<string | null>(null)
|
||||
const copyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
return () => { if (copyTimerRef.current) clearTimeout(copyTimerRef.current) }
|
||||
}, [])
|
||||
|
||||
const mcpEndpoint = `${window.location.origin}/mcp`
|
||||
const mcpJsonConfigOAuth = `{
|
||||
@@ -195,7 +200,8 @@ export default function IntegrationsTab(): React.ReactElement {
|
||||
const handleCopy = (text: string, key: string) => {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopiedKey(key)
|
||||
setTimeout(() => setCopiedKey(null), 2000)
|
||||
if (copyTimerRef.current) clearTimeout(copyTimerRef.current)
|
||||
copyTimerRef.current = setTimeout(() => setCopiedKey(null), 2000)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import Modal from '../shared/Modal'
|
||||
import { tripsApi, authApi, shareApi } from '../../api/client'
|
||||
import { useToast } from '../shared/Toast'
|
||||
@@ -40,6 +40,11 @@ function ShareLinkSection({ tripId, t }: { tripId: number; t: (key: string, para
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [perms, setPerms] = useState({ share_map: true, share_bookings: true, share_packing: false, share_budget: false, share_collab: false })
|
||||
const toast = useToast()
|
||||
const copyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
return () => { if (copyTimerRef.current) clearTimeout(copyTimerRef.current) }
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
shareApi.getLink(tripId).then(d => {
|
||||
@@ -77,7 +82,8 @@ function ShareLinkSection({ tripId, t }: { tripId: number; t: (key: string, para
|
||||
if (shareUrl) {
|
||||
navigator.clipboard.writeText(shareUrl)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
if (copyTimerRef.current) clearTimeout(copyTimerRef.current)
|
||||
copyTimerRef.current = setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { createContext, useContext, useState, useCallback, useEffect } from 'react'
|
||||
import React, { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import { CheckCircle, XCircle, AlertCircle, Info, X } from 'lucide-react'
|
||||
|
||||
type ToastType = 'success' | 'error' | 'warning' | 'info'
|
||||
@@ -28,18 +28,27 @@ const ICON_COLORS: Record<ToastType, string> = {
|
||||
|
||||
export function ToastContainer() {
|
||||
const [toasts, setToasts] = useState<Toast[]>([])
|
||||
const timersRef = useRef<ReturnType<typeof setTimeout>[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
timersRef.current.forEach(clearTimeout)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const addToast = useCallback((message: string, type: ToastType = 'info', duration: number = 3000) => {
|
||||
const id = ++toastIdCounter
|
||||
setToasts(prev => [...prev, { id, message, type, duration, removing: false }])
|
||||
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
const t1 = setTimeout(() => {
|
||||
setToasts(prev => prev.map(t => t.id === id ? { ...t, removing: true } : t))
|
||||
setTimeout(() => {
|
||||
const t2 = setTimeout(() => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id))
|
||||
}, 400)
|
||||
timersRef.current.push(t2)
|
||||
}, duration)
|
||||
timersRef.current.push(t1)
|
||||
}
|
||||
|
||||
return id
|
||||
@@ -47,9 +56,10 @@ export function ToastContainer() {
|
||||
|
||||
const removeToast = useCallback((id: number) => {
|
||||
setToasts(prev => prev.map(t => t.id === id ? { ...t, removing: true } : t))
|
||||
setTimeout(() => {
|
||||
const t = setTimeout(() => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id))
|
||||
}, 400)
|
||||
timersRef.current.push(t)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
Reference in New Issue
Block a user