diff --git a/llm-fe/src/llm-fe/auth/sso.js b/llm-fe/src/llm-fe/auth/sso.js index 165f3d9..9834bd4 100644 --- a/llm-fe/src/llm-fe/auth/sso.js +++ b/llm-fe/src/llm-fe/auth/sso.js @@ -6,7 +6,7 @@ /** * Absolute backend OAuth start URL. * @param {'google'|'microsoft'} provider - * @param {'login'|'signup'} intent + * @param {'login'|'signup'|'link_drive'|'link_company_drive'} intent * @returns {string} */ export function oauthStartUrl(provider, intent = 'login') { @@ -18,7 +18,7 @@ export function oauthStartUrl(provider, intent = 'login') { /** * Begin browser redirect to IdP via backend. * @param {'google'|'microsoft'} provider - * @param {'login'|'signup'} intent + * @param {'login'|'signup'|'link_drive'|'link_company_drive'} intent */ export function startOAuth(provider, intent = 'login') { window.location.assign(oauthStartUrl(provider, intent)); diff --git a/llm-fe/src/llm-fe/components/ConversationDetailCard/ConversationDetailCard.test.tsx b/llm-fe/src/llm-fe/components/ConversationDetailCard/ConversationDetailCard.test.tsx index 7c76895..66dfde0 100644 --- a/llm-fe/src/llm-fe/components/ConversationDetailCard/ConversationDetailCard.test.tsx +++ b/llm-fe/src/llm-fe/components/ConversationDetailCard/ConversationDetailCard.test.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; import { ThemeProvider } from 'styled-components'; import ConversationDetailCard from './ConversationDetailCard'; @@ -19,9 +20,11 @@ const renderCard = (props: { user_created: boolean; }) => render( - - - + + + + + ); describe('ConversationDetailCard', () => { @@ -43,4 +46,38 @@ describe('ConversationDetailCard', () => { expect(screen.getByText('Hi')).toBeInTheDocument(); expect(screen.queryByTestId('message-token-usage')).not.toBeInTheDocument(); }); + + it('shows a friendly upgrade bubble for RAG feature_not_allowed errors (#85)', () => { + renderCard({ + message: JSON.stringify({ + type: 'error', + code: 'feature_not_allowed', + content: 'Your plan does not include rag document search.', + details: { feature: 'rag' }, + }), + user_created: false, + }); + + expect( + screen.getByText('Your plan does not include rag document search.') + ).toBeInTheDocument(); + const upgradeLink = screen.getByRole('link', { name: /upgrade your plan/i }); + expect(upgradeLink).toBeInTheDocument(); + expect(upgradeLink).toHaveAttribute('href', '/account/'); + }); + + it('renders unrelated feature_not_allowed errors as a normal inline error, not the upgrade bubble', () => { + renderCard({ + message: JSON.stringify({ + type: 'error', + code: 'feature_not_allowed', + content: 'Your plan does not include image generation.', + details: { feature: 'image_generation' }, + }), + user_created: false, + }); + + expect(screen.queryByRole('link', { name: /upgrade your plan/i })).not.toBeInTheDocument(); + expect(screen.getByText(/image generation/i)).toBeInTheDocument(); + }); }); diff --git a/llm-fe/src/llm-fe/components/ConversationDetailCard/ConversationDetailCard.tsx b/llm-fe/src/llm-fe/components/ConversationDetailCard/ConversationDetailCard.tsx index fabe98c..58bc55f 100644 --- a/llm-fe/src/llm-fe/components/ConversationDetailCard/ConversationDetailCard.tsx +++ b/llm-fe/src/llm-fe/components/ConversationDetailCard/ConversationDetailCard.tsx @@ -1,6 +1,8 @@ import React from "react"; import Markdown from "markdown-to-jsx"; +import { Link } from "react-router-dom"; import styled, { keyframes } from "styled-components"; +import { isRagFeatureNotAllowed, parseChatErrorPayload } from "../../utils/chatErrors"; const fadeIn = keyframes` from { opacity: 0; transform: translateY(10px); } @@ -91,6 +93,27 @@ const LoadingContainer = styled.div` padding: 0.5rem; `; +const UpgradeNotice = styled.div` + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.65rem; +`; + +const UpgradeLink = styled(Link)` + background: ${(props) => props.theme.main}; + color: #fff; + text-decoration: none; + font-weight: 600; + font-size: 0.85rem; + padding: 0.5rem 1rem; + border-radius: 0.5rem; + + &:hover { + opacity: 0.9; + } +`; + type ConversationDetailCardProps = { message: string; user_created: boolean; @@ -133,6 +156,23 @@ const ConversationDetailCard = ({ ); } + const errorPayload = parseChatErrorPayload(message); + if (errorPayload && isRagFeatureNotAllowed(errorPayload)) { + return ( + + + + + {errorPayload.content || + "Document search (RAG) isn't included in your current plan."} + + Upgrade your plan + + + + ); + } + let contentToAdd = message; try { const parsedMessage = JSON.parse(message); diff --git a/llm-fe/src/llm-fe/components/DriveConnectionsSection/DriveConnectionsSection.tsx b/llm-fe/src/llm-fe/components/DriveConnectionsSection/DriveConnectionsSection.tsx new file mode 100644 index 0000000..58fabf5 --- /dev/null +++ b/llm-fe/src/llm-fe/components/DriveConnectionsSection/DriveConnectionsSection.tsx @@ -0,0 +1,379 @@ +import React, { useCallback, useEffect, useState } from "react"; +import styled from "styled-components"; +import { + DriveConnectIntent, + DriveConnectionKind, + DriveConnectionType, + DriveProvider, + connectDrive, + disconnectDriveConnection, + fetchDriveConnections, + parseResourceIdsInput, + saveDriveResourceSelection, + syncDriveConnection, +} from "../../utils/drive"; + +const Section = styled.div` + background: ${({ theme }) => theme.colors.cardBackground}; + backdrop-filter: blur(10px); + border: 1px solid ${({ theme }) => theme.colors.cardBorder}; + border-radius: 1rem; + padding: 2rem; + width: 100%; + max-width: 1000px; + margin-bottom: 2rem; + box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37); +`; + +const SectionTitle = styled.h2` + font-size: 1.8rem; + margin-bottom: 0.5rem; + color: ${({ theme }) => theme.colors.text}; + border-bottom: 1px solid ${({ theme }) => theme.colors.cardBorder}; + padding-bottom: 1rem; +`; + +const SectionDescription = styled.p` + color: ${({ theme }) => theme.colors.text}; + opacity: 0.7; + margin: 0.75rem 0 1.5rem 0; + line-height: 1.5; +`; + +const ConnectButtonRow = styled.div` + display: flex; + flex-wrap: wrap; + gap: 1rem; + margin-bottom: 1.5rem; +`; + +const ConnectButton = styled.button` + background: ${({ theme }) => theme.main}; + border: none; + border-radius: 0.5rem; + color: #fff; + padding: 0.7rem 1.25rem; + font-weight: 600; + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease; + + &:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px ${({ theme }) => theme.main}66; + } +`; + +const ConnectionList = styled.div` + display: flex; + flex-direction: column; + gap: 1rem; +`; + +const ConnectionCard = styled.div` + border: 1px solid ${({ theme }) => theme.colors.cardBorder}; + border-radius: 0.75rem; + padding: 1.25rem; +`; + +const ConnectionHeader = styled.div` + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; + flex-wrap: wrap; +`; + +const ConnectionTitle = styled.strong` + color: ${({ theme }) => theme.colors.text}; + font-size: 1.05rem; +`; + +const ConnectionMeta = styled.p` + margin: 0.35rem 0 0 0; + font-size: 0.85rem; + color: ${({ theme }) => theme.colors.text}; + opacity: 0.65; +`; + +const ConnectionActions = styled.div` + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +`; + +const SmallButton = styled.button` + background: transparent; + border: 1px solid ${({ theme }) => theme.colors.cardBorder}; + border-radius: 0.5rem; + color: ${({ theme }) => theme.colors.text}; + padding: 0.45rem 0.9rem; + font-weight: 600; + font-size: 0.85rem; + cursor: pointer; + + &:hover { + background: ${({ theme }) => theme.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'}; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +`; + +const DangerButton = styled(SmallButton)` + color: #ff6b6b; + border-color: rgba(255, 107, 107, 0.4); + + &:hover { + background: rgba(255, 71, 87, 0.1); + } +`; + +const ResourceForm = styled.div` + margin-top: 1rem; + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +`; + +const ResourceInput = styled.input` + flex: 1; + min-width: 200px; + background: ${({ theme }) => theme.darkMode ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.05)'}; + border: 1px solid ${({ theme }) => theme.colors.cardBorder}; + border-radius: 0.5rem; + padding: 0.6rem 0.8rem; + color: ${({ theme }) => theme.colors.text}; + + &::placeholder { + color: ${({ theme }) => theme.darkMode ? 'rgba(255, 255, 255, 0.4)' : 'rgba(0, 0, 0, 0.4)'}; + } +`; + +const ResourceTagList = styled.div` + margin-top: 0.75rem; + display: flex; + flex-wrap: wrap; + gap: 0.4rem; +`; + +const ResourceTag = styled.span` + background: ${({ theme }) => theme.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.08)'}; + color: ${({ theme }) => theme.colors.text}; + border-radius: 999px; + padding: 0.25rem 0.75rem; + font-size: 0.8rem; +`; + +const EmptyState = styled.p` + color: ${({ theme }) => theme.colors.text}; + opacity: 0.6; +`; + +const PROVIDER_LABELS: Record = { + google: "Google Drive", + microsoft: "OneDrive", +}; + +type PendingAction = 'sync' | 'save' | 'disconnect'; + +type DriveConnectionsSectionProps = { + kind: DriveConnectionKind; + title: string; + description?: string; + connectIntent: DriveConnectIntent; +}; + +const DriveConnectionsSection = ({ + kind, + title, + description, + connectIntent, +}: DriveConnectionsSectionProps): JSX.Element => { + const [connections, setConnections] = useState([]); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(false); + const [connectError, setConnectError] = useState(null); + const [resourceInputs, setResourceInputs] = useState>({}); + const [pendingAction, setPendingAction] = useState>({}); + + const loadConnections = useCallback(async () => { + setLoading(true); + setLoadError(false); + try { + const all = await fetchDriveConnections(); + setConnections(all.filter((conn) => (conn.kind || 'personal') === kind)); + } catch { + setLoadError(true); + } finally { + setLoading(false); + } + }, [kind]); + + useEffect(() => { + loadConnections(); + }, [loadConnections]); + + const setAction = (id: number, action?: PendingAction) => { + setPendingAction((prev) => ({ ...prev, [id]: action })); + }; + + const handleDisconnect = async (id: number) => { + setAction(id, 'disconnect'); + try { + await disconnectDriveConnection(id); + setConnections((prev) => prev.filter((conn) => conn.id !== id)); + } catch (err) { + console.log(err); + } finally { + setAction(id, undefined); + } + }; + + const handleSync = async (id: number) => { + setAction(id, 'sync'); + try { + await syncDriveConnection(id); + await loadConnections(); + } catch (err) { + console.log(err); + } finally { + setAction(id, undefined); + } + }; + + const handleSaveResources = async (id: number) => { + const resourceIds = parseResourceIdsInput(resourceInputs[id] || ''); + if (resourceIds.length === 0) { + return; + } + setAction(id, 'save'); + try { + const updated = await saveDriveResourceSelection(id, { + resource_ids: resourceIds, + resource_labels: resourceIds, + }); + setConnections((prev) => prev.map((conn) => (conn.id === id ? { ...conn, ...updated } : conn))); + setResourceInputs((prev) => ({ ...prev, [id]: '' })); + } catch (err) { + console.log(err); + } finally { + setAction(id, undefined); + } + }; + + return ( +
+ {title} + {description && {description}} + + + { + setConnectError(null); + void connectDrive('google', connectIntent).catch((err) => { + console.error(err); + setConnectError('Could not start Google Drive connect. Try again.'); + }); + }} + > + Connect Google Drive + + { + setConnectError(null); + void connectDrive('microsoft', connectIntent).catch((err) => { + console.error(err); + setConnectError('Could not start OneDrive connect. Try again.'); + }); + }} + > + Connect OneDrive + + + + {connectError && {connectError}} + + {loading ? ( + Loading connections… + ) : loadError ? ( + Could not load drive connections. Try again later. + ) : connections.length === 0 ? ( + No drives connected yet. + ) : ( + + {connections.map((conn) => { + const action = pendingAction[conn.id]; + const selectedLabels = conn.selected_resource_labels?.length + ? conn.selected_resource_labels + : conn.selected_resource_ids || []; + + return ( + + +
+ {PROVIDER_LABELS[conn.provider] || conn.provider} + + {conn.external_account_email || 'Connected account'} + {conn.last_sync_status ? ` · ${conn.last_sync_status}` : ''} + {conn.last_sync_at + ? ` · Last synced ${new Date(conn.last_sync_at).toLocaleString()}` + : ''} + +
+ + handleSync(conn.id)} + disabled={Boolean(action)} + > + {action === 'sync' ? 'Syncing…' : 'Sync now'} + + handleDisconnect(conn.id)} + disabled={Boolean(action)} + > + {action === 'disconnect' ? 'Disconnecting…' : 'Disconnect'} + + +
+ + {selectedLabels.length > 0 && ( + + {selectedLabels.map((label, idx) => ( + {label} + ))} + + )} + + + + setResourceInputs((prev) => ({ ...prev, [conn.id]: e.target.value })) + } + /> + handleSaveResources(conn.id)} + disabled={action === 'save' || !(resourceInputs[conn.id] || '').trim()} + > + {action === 'save' ? 'Saving…' : 'Save folders'} + + +
+ ); + })} +
+ )} +
+ ); +}; + +export default DriveConnectionsSection; diff --git a/llm-fe/src/llm-fe/components/Header2/Header2.test.tsx b/llm-fe/src/llm-fe/components/Header2/Header2.test.tsx new file mode 100644 index 0000000..0e3ddc5 --- /dev/null +++ b/llm-fe/src/llm-fe/components/Header2/Header2.test.tsx @@ -0,0 +1,97 @@ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { ThemeProvider } from 'styled-components'; +import Header2 from './Header2'; +import { AuthContext } from '../../contexts/AuthContext'; +import { AccountContext } from '../../contexts/AccountContext'; +import { resetSubscriptionCache } from '../../hooks/useSubscription'; + +const mockGet = jest.fn(); +const mockPost = jest.fn(); + +jest.mock('../../../axiosApi', () => ({ + axiosInstance: { + get: (...args: unknown[]) => mockGet(...args), + post: (...args: unknown[]) => mockPost(...args), + defaults: { headers: { common: {} as Record } }, + }, + applyAccessToken: jest.fn(), +})); + +const theme = { + main: '#4a90e2', + focus: '#224466', + darkMode: true, + colors: { + text: '#ffffff', + cardBorder: 'rgba(255,255,255,0.1)', + }, +}; + +const renderHeader = () => + render( + + + + + + + + + + ); + +const subscriptionWithRag = (rag: boolean) => ({ + data: { + plan: { + slug: rag ? 'pro' : 'standard', + name: rag ? 'Pro' : 'Standard', + features: { + text_generation: true, + image_generation: rag, + rag, + all_future_features: false, + }, + }, + status: 'active', + source: 'stripe', + needs_checkout: false, + stripe_subscription_id: 'sub_1', + usage: {}, + }, +}); + +describe('Header2 (#81 subscription-aware Documents nav link)', () => { + beforeEach(() => { + mockGet.mockReset(); + mockPost.mockReset(); + resetSubscriptionCache(); + }); + + it('hides the Documents link while the plan does not include RAG', async () => { + mockGet.mockResolvedValue(subscriptionWithRag(false)); + + renderHeader(); + + await waitFor(() => expect(mockGet).toHaveBeenCalledWith('/finance/subscription/')); + expect(screen.queryByText('Documents')).not.toBeInTheDocument(); + }); + + it('shows the Documents link once the plan includes RAG', async () => { + mockGet.mockResolvedValue(subscriptionWithRag(true)); + + renderHeader(); + + // Desktop nav + mobile dropdown both render a "Documents" link. + expect(await screen.findAllByText('Documents')).toHaveLength(2); + }); +}); diff --git a/llm-fe/src/llm-fe/components/Header2/Header2.tsx b/llm-fe/src/llm-fe/components/Header2/Header2.tsx index 0bea3df..98f41eb 100644 --- a/llm-fe/src/llm-fe/components/Header2/Header2.tsx +++ b/llm-fe/src/llm-fe/components/Header2/Header2.tsx @@ -5,6 +5,7 @@ import { AuthContext } from '../../contexts/AuthContext'; import { AccountContext } from '../../contexts/AccountContext'; import { applyAccessToken, axiosInstance } from '../../../axiosApi'; import { clearTokens, getRefreshToken } from '../../auth/tokenStorage'; +import { useSubscription } from '../../hooks/useSubscription'; import hesychiaMark from '../../assets/brand/hesychia-mark.png'; const HeaderContainer = styled.header` @@ -157,6 +158,7 @@ const Header2 = ({ absolute = false, light = false, isMini = false }: Header2Pro const navigate = useNavigate(); const [isMenuOpen, setIsMenuOpen] = useState(false); const theme = useTheme(); + const { hasRag } = useSubscription(); const handleSignOut = async () => { try { @@ -189,7 +191,7 @@ const Header2 = ({ absolute = false, light = false, isMini = false }: Header2Pro