LibreChat/client/src/components/Nav/SettingsTabs/Data/ImportConversations.tsx
Danny Avila 7b368916d5
🔑 fix: Auth-Aware Startup Config Caching for Fresh Sessions (#12505)
* fix: auth-aware config caching for fresh sessions

- Add auth state to startup config query key via shared `startupConfigKey`
  builder so login (unauthenticated) and chat (authenticated) configs are
  cached independently
- Disable queries during login onMutate to prevent premature unauthenticated
  refetches after cache clear
- Re-enable queries in setUserContext only after setTokenHeader runs, with
  positive-only guard to avoid redundant disable on logout
- Update all getQueryData call sites to use the shared key builder
- Fall back to getConfigDefaults().interface in useEndpoints, hoisted to
  module-level constant to avoid per-render recomputation

* fix: address review findings for auth-aware config caching

- Move defaultInterface const after all imports in ModelSelector.tsx
- Remove dead QueryKeys import, use import type for TStartupConfig
  in ImportConversations.tsx
- Spread real exports in useQueryParams.spec.ts mock to preserve
  startupConfigKey, fixing TypeError in all 6 tests

* chore: import order

* fix: re-enable queries on login failure

When login fails, onSuccess never fires so queriesEnabled stays
false. Re-enable in onError so the login page can re-fetch config
(needed for LDAP username validation and social login options).
2026-04-01 17:20:39 -04:00

144 lines
4.4 KiB
TypeScript

import { useState, useRef, useCallback } from 'react';
import { Import } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query';
import type { TStartupConfig } from 'librechat-data-provider';
import { Spinner, useToastContext, Label, Button } from '@librechat/client';
import { startupConfigKey, useUploadConversationsMutation } from '~/data-provider';
import { NotificationSeverity } from '~/common';
import { useLocalize } from '~/hooks';
import { cn, logger } from '~/utils';
function ImportConversations() {
const localize = useLocalize();
const queryClient = useQueryClient();
const { showToast } = useToastContext();
const fileInputRef = useRef<HTMLInputElement>(null);
const [isUploading, setIsUploading] = useState(false);
const handleSuccess = useCallback(() => {
showToast({
message: localize('com_ui_import_conversation_success'),
status: NotificationSeverity.SUCCESS,
});
setIsUploading(false);
}, [localize, showToast]);
const handleError = useCallback(
(error: unknown) => {
logger.error('Import error:', error);
setIsUploading(false);
const isUnsupportedType = error?.toString().includes('Unsupported import type');
showToast({
message: localize(
isUnsupportedType
? 'com_ui_import_conversation_file_type_error'
: 'com_ui_import_conversation_error',
),
status: NotificationSeverity.ERROR,
});
},
[localize, showToast],
);
const uploadFile = useUploadConversationsMutation({
onSuccess: handleSuccess,
onError: handleError,
onMutate: () => setIsUploading(true),
});
const handleFileUpload = useCallback(
async (file: File) => {
try {
const startupConfig = queryClient.getQueryData<TStartupConfig>(startupConfigKey(true));
const maxFileSize = startupConfig?.conversationImportMaxFileSize;
if (maxFileSize && file.size > maxFileSize) {
const size = (maxFileSize / (1024 * 1024)).toFixed(2);
showToast({
message: localize('com_error_files_upload_too_large', { 0: size }),
status: NotificationSeverity.ERROR,
});
setIsUploading(false);
return;
}
const formData = new FormData();
formData.append('file', file, encodeURIComponent(file.name || 'File'));
uploadFile.mutate(formData);
} catch (error) {
logger.error('File processing error:', error);
setIsUploading(false);
showToast({
message: localize('com_ui_import_conversation_upload_error'),
status: NotificationSeverity.ERROR,
});
}
},
[uploadFile, showToast, localize, queryClient],
);
const handleFileChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
setIsUploading(true);
handleFileUpload(file);
}
event.target.value = '';
},
[handleFileUpload],
);
const handleImportClick = useCallback(() => {
fileInputRef.current?.click();
}, []);
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLButtonElement>) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleImportClick();
}
},
[handleImportClick],
);
const isImportDisabled = isUploading;
return (
<div className="flex items-center justify-between">
<Label id="import-conversation-label">{localize('com_ui_import_conversation_info')}</Label>
<Button
variant="outline"
onClick={handleImportClick}
onKeyDown={handleKeyDown}
disabled={isImportDisabled}
aria-label={localize('com_ui_import')}
aria-labelledby="import-conversation-label"
>
{isUploading ? (
<>
<Spinner className="mr-1 w-4" />
<span>{localize('com_ui_importing')}</span>
</>
) : (
<>
<Import className="mr-1 flex h-4 w-4 items-center stroke-1" aria-hidden="true" />
<span>{localize('com_ui_import')}</span>
</>
)}
</Button>
<input
ref={fileInputRef}
type="file"
className={cn('hidden')}
accept=".json"
onChange={handleFileChange}
aria-hidden="true"
/>
</div>
);
}
export default ImportConversations;