Bring the connectors UI in line with DESIGN.md

From a design review against frontend/DESIGN.md:
- Drawer panels use the subtle surface; ToolPermissions takes the
  surface it sits on (outline in the wizard, subtle in the drawer).
- The OAuth sign-in is the wizard's footer action with a Cancel
  (useConnectorAuth), and sign-in buttons show the service's own glyph
  instead of a hard-coded Drive logo. Add Tool's links moved into its
  ModalActions footer.
- Failed saves, disconnects and connector creates say so in a toast;
  the admin page reports save errors as a toast too.
- Capability badges are neutral; status hues are left for states.
- Row titles use SectionHeader actions, setup settings a DescriptionList,
  empty lists EmptyState, retry and row buttons their pill roles.
- The drawer names its close button and describes itself with
  SheetDescription; counts are formatted for the app language.
- One ConnectorSetupNotice for the drawer and Upload; warning Alerts use
  TriangleAlert; its link keeps the Alert colour (Approved exceptions).
- Tools and Sources tiles show the same "via <service>" meta line; the
  paused badge explains itself in a Tooltip.
- On phones the category filter is a Select, empty categories are not
  offered, and a composer's capability filter says so with Show all.
- The admin page uses PageToolbar and SectionHeader descriptions, and is
  English throughout.
This commit is contained in:
arc53-machine committed 2026-09-28 20:33:34 +01:00
1 parent a564dea401
commit ca8fb94777
28 files changed
+649 -382

No files matched your search

+1
View File
@@ -1588,6 +1588,7 @@ list stays reviewable.
| `settings/PersonalAccessTokens.tsx` | `shadcn/no-restyle` | Token scope chips are identifiers, so the `neutral` Badge is set in mono (`font-mono`). One disable. |
| `agents/workflow/WorkflowPreview.tsx` | `shadcn/no-restyle` | A step's state changes (keys and values the app serialised) are `neutral` Badges set in mono (`font-mono`), like token scopes. One disable. |
| `settings/traces/TraceChips.tsx` | `shadcn/no-restyle` | Trace stat chips (durations, counts) use tabular figures so they don't jitter between rows (`tabular-nums` on Badge). One disable. |
| `connectors/ConnectorSetupNotice.tsx` | `shadcn/no-restyle` | The setup-guide link inside the needs-setup warning Alert keeps the Alert's status colour (`text-current` on `link inline`). One disable. |
| `modals/MCPServerModal.tsx` | `shadcn/no-restyle` | The authorization link inside the test-result Alert keeps the Alert's status colour (`text-current` on `link inline`). One disable. |
| `components/MermaidRenderer.tsx` | `shadcn/no-restyle` | The zoom readout between − and + is a `link inline` Button on the `bg-black/70` overlay; it keeps the overlay's white 12px regular text (`text-xs font-normal text-current`). One disable, beside the two zoom-button ones above. |
| `conversation/SharedConversation.tsx` | `shadcn/no-restyle` | The "DocsGPT" link sits in the `/share/:id` page's regular-weight byline (`font-normal`). One disable. |
+21 -6
View File
@@ -16,6 +16,9 @@ vi.mock('../api/services/connectorsService', () => ({
},
}));
import actionToastReducer, {
selectActionToast,
} from '../notifications/actionToastSlice';
import { prefSlice } from '../preferences/preferenceSlice';
import Connectors from './Connectors';
@@ -79,10 +82,17 @@ describe('Admin Connectors', () => {
container.remove();
});
const render = async () => {
const store = configureStore({
reducer: { preference: prefSlice.reducer },
let store: ReturnType<typeof makeStore>;
const makeStore = () =>
configureStore({
reducer: {
preference: prefSlice.reducer,
actionToast: actionToastReducer,
},
});
const render = async () => {
store = makeStore();
await act(async () => {
root.render(
<Provider store={store}>
@@ -111,7 +121,9 @@ describe('Admin Connectors', () => {
it('warns when credentials use the default encryption key', async () => {
getAdmin.mockResolvedValue(payload({ default_encryption_key: true }));
await render();
expect(container.textContent).toContain('admin.connectors.defaultKey');
expect(container.textContent).toContain(
'Set ENCRYPTION_SECRET_KEY before connecting services.',
);
expect(container.textContent).toContain('docsgpt connectors reencrypt');
});
@@ -138,7 +150,7 @@ describe('Admin Connectors', () => {
).toBe('false');
});
it('shows an error and keeps the page when a save fails', async () => {
it('reports a failed save in a toast and keeps the page', async () => {
getAdmin.mockResolvedValue(payload());
updateAdmin.mockResolvedValue({ success: false });
await render();
@@ -146,7 +158,10 @@ describe('Admin Connectors', () => {
container.querySelector<HTMLButtonElement>('#allow-custom-mcp')!.click(),
);
expect(updateAdmin).toHaveBeenCalledWith({ allow_custom_mcp: false }, null);
expect(container.textContent).toContain('Could not save the change.');
expect(selectActionToast(store.getState())).toMatchObject({
variant: 'destructive',
message: 'Could not save the change.',
});
expect(container.querySelectorAll('tbody tr')).toHaveLength(2);
});
+32 -32
View File
@@ -1,14 +1,18 @@
import { ExternalLink, TriangleAlert } from 'lucide-react';
import { ExternalLink, Info, TriangleAlert } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import connectorsService from '../api/services/connectorsService';
import CopyButton from '../components/CopyButton';
import PageToolbar from '../components/PageToolbar';
import { Alert, AlertDescription, AlertTitle } from '../components/ui/alert';
import { Badge } from '../components/ui/badge';
import { Button } from '../components/ui/button';
import { Card } from '../components/ui/card';
import {
DescriptionItem,
DescriptionList,
} from '../components/ui/description-list';
import { LoadingState } from '../components/ui/loading-state';
import { Modal } from '../components/ui/modal';
import { SectionHeader } from '../components/ui/section-header';
@@ -31,6 +35,7 @@ import {
TableRow,
} from '../components/ui/table';
import ConnectorIcon from '../connectors/ConnectorIcon';
import { showActionToast } from '../notifications/actionToastSlice';
import { selectToken } from '../preferences/preferenceSlice';
import { LoadError, fmtNumber } from './AdminUI';
@@ -105,23 +110,22 @@ function SetupGuide({
</section>
<section className="flex flex-col gap-2">
<SectionHeader as="h3" size="xs" title="Server settings" />
<ul className="flex flex-col gap-2">
<DescriptionList layout="justified" size="xs">
{connector.required_settings.map((setting) => (
<li
<DescriptionItem
key={setting.name}
className="flex items-center justify-between gap-3"
label={<code className="font-mono">{setting.name}</code>}
>
<code className="font-mono text-xs">{setting.name}</code>
<Badge variant={setting.set ? 'success' : 'warning'}>
{setting.set ? 'Set' : 'Missing'}
</Badge>
</li>
</DescriptionItem>
))}
</ul>
</DescriptionList>
</section>
{connector.key === 'google_drive' && (
<Alert variant="info" role="note">
<TriangleAlert />
<Info />
<AlertDescription>
Publish the Google OAuth app (or use an internal Workspace app).
Apps left in Testing get refresh tokens that expire after seven
@@ -152,11 +156,10 @@ function SetupGuide({
* server. English only, like the rest of the admin pages.
*/
export default function Connectors() {
const { t } = useTranslation();
const dispatch = useDispatch();
const token = useSelector(selectToken);
const [data, setData] = useState<AdminConnectorsData | null>(null);
const [loading, setLoading] = useState(true);
const [saveError, setSaveError] = useState(false);
const [guide, setGuide] = useState<AdminConnector | null>(null);
const load = useCallback(async () => {
@@ -178,13 +181,19 @@ export default function Connectors() {
policies?: Record<string, { enabled?: boolean; credential_mode?: Policy }>;
allow_custom_mcp?: boolean;
}) => {
setSaveError(false);
const failed = () =>
dispatch(
showActionToast({
variant: 'destructive',
message: 'Could not save the change.',
}),
);
try {
const next = await connectorsService.updateAdmin(body, token);
if (next?.success) setData(next);
else setSaveError(true);
else failed();
} catch {
setSaveError(true);
failed();
}
};
@@ -194,17 +203,14 @@ export default function Connectors() {
return (
<div className="flex flex-col gap-8">
<p className="text-muted-foreground text-sm">
Choose which connectors members can use and whose account a shared tool
runs with. A connector that still needs server settings starts turned
off and is hidden from members; it turns on once its settings are in
place, unless you switch it off.
</p>
<PageToolbar intro="Choose which connectors members can use and whose account a shared tool runs with. A connector that still needs server settings starts turned off and is hidden from members; it turns on once its settings are in place, unless you switch it off." />
{data.default_encryption_key && (
<Alert variant="destructive">
<TriangleAlert />
<AlertTitle>{t('admin.connectors.defaultKey')}</AlertTitle>
<AlertTitle>
Set ENCRYPTION_SECRET_KEY before connecting services.
</AlertTitle>
<AlertDescription>
Stored credentials are encrypted with ENCRYPTION_SECRET_KEY, which
still has its public default value. Set your own, keep the old one
@@ -216,12 +222,6 @@ export default function Connectors() {
</AlertDescription>
</Alert>
)}
{saveError && (
<Alert variant="destructive">
<TriangleAlert />
<AlertDescription>Could not save the change.</AlertDescription>
</Alert>
)}
<SettingRows>
<SettingRow
@@ -240,10 +240,10 @@ export default function Connectors() {
</SettingRows>
<section className="flex flex-col gap-3">
<SectionHeader title="Redirect URIs" />
<p className="text-muted-foreground text-sm">
Register these with each provider&apos;s OAuth app.
</p>
<SectionHeader
title="Redirect URIs"
description="Register these with each provider's OAuth app."
/>
<div className="grid grid-cols-1 gap-x-4 gap-y-3 lg:grid-cols-2">
<div className="flex flex-col gap-1.5">
<span className="text-muted-foreground text-xs">
+37 -14
View File
@@ -9,30 +9,35 @@ import { isTrustedConnectorMessage } from '../utils/connectorAuthUtils';
import { Alert, AlertDescription } from './ui/alert';
import { Button } from './ui/button';
interface ConnectorAuthProps {
interface ConnectorAuthOptions {
provider: string;
/** Reconnect this connection (the same account) rather than add one. */
connectionId?: string;
onSuccess: (data: { connection_id: string; user_email: string }) => void;
onError: (error: string) => void;
}
interface ConnectorAuthProps extends ConnectorAuthOptions {
label?: string;
/** The service's glyph on the sign-in button (e.g. a `ConnectorIcon`). */
icon?: React.ReactNode;
isConnected?: boolean;
userEmail?: string;
onDisconnect?: () => void;
errorMessage?: string;
}
const ConnectorAuth: React.FC<ConnectorAuthProps> = ({
/**
* The OAuth pop-up sign-in: returns the function to call from a click. It
* opens the pop-up inside the gesture, points it at the provider and reports
* the connection it created (or an error) once the callback page answers.
*/
export function useConnectorAuth({
provider,
connectionId,
onSuccess,
onError,
label,
isConnected = false,
userEmail = '',
onDisconnect,
errorMessage,
}) => {
}: ConnectorAuthOptions): () => void {
const { t } = useTranslation();
const token = useSelector(selectToken);
const completedRef = useRef(false);
@@ -209,6 +214,29 @@ const ConnectorAuth: React.FC<ConnectorAuthProps> = ({
};
}, []);
return handleAuth;
}
const ConnectorAuth: React.FC<ConnectorAuthProps> = ({
provider,
connectionId,
onSuccess,
onError,
label,
icon,
isConnected = false,
userEmail = '',
onDisconnect,
errorMessage,
}) => {
const { t } = useTranslation();
const handleAuth = useConnectorAuth({
provider,
connectionId,
onSuccess,
onError,
});
return (
<>
{errorMessage && (
@@ -242,12 +270,7 @@ const ConnectorAuth: React.FC<ConnectorAuthProps> = ({
</Alert>
) : (
<Button type="button" onClick={handleAuth} className="w-full">
<svg className="size-5" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M6.28 3l5.72 10H24l-5.72-10H6.28zm11.44 0L12 13l5.72 10H24L18.28 3h-.56zM0 13l5.72 10h5.72L5.72 13H0z"
/>
</svg>
{icon}
{label}
</Button>
)}
+6 -1
View File
@@ -13,6 +13,8 @@ import { formatDateTime } from '../utils/dateTimeUtils';
import { useDispatch, useSelector } from 'react-redux';
import connectorsService from '../api/services/connectorsService';
import ConnectorAuth from '../components/ConnectorAuth';
import ConnectorIcon from '../connectors/ConnectorIcon';
import { connectorIconKey } from '../connectors/i18n';
import {
loadConnectors,
selectConnections,
@@ -453,7 +455,7 @@ export const FilePicker: React.FC<CloudFilePickerProps> = ({
value={activeConnectionId ?? undefined}
onValueChange={switchAccount}
>
<SelectTrigger size="field" className="w-full">
<SelectTrigger size="field" shape="pill" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -472,6 +474,9 @@ export const FilePicker: React.FC<CloudFilePickerProps> = ({
label={t('filePicker.connectTo', {
provider: getProviderConfig(provider).displayName,
})}
icon={
<ConnectorIcon icon={connectorIconKey(provider)} className="size-5" />
}
onSuccess={(data) => {
setAuthError('');
dispatch(loadConnectors({ token }));
@@ -17,6 +17,7 @@ import {
selectConnections,
} from '../connectors/connectorsSlice';
import type { AppDispatch } from '../store';
import ConnectorIcon from '../connectors/ConnectorIcon';
import ConnectorAuth from './ConnectorAuth';
import SkeletonLoader from './SkeletonLoader';
import { Button } from './ui/button';
@@ -239,6 +240,7 @@ const GoogleDrivePicker: React.FC<GoogleDrivePickerProps> = ({
<ConnectorAuth
provider="google_drive"
label={t('modals.uploadDoc.connectors.googleDrive.connect')}
icon={<ConnectorIcon icon="drive" className="size-5" />}
onSuccess={(data) => {
setAuthError('');
dispatch(loadConnectors({ token }));
@@ -1,5 +1,6 @@
import { ArrowRight, Wrench } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import {
MultiSelectPopover,
@@ -40,16 +41,16 @@ export default function ToolsTrigger({
footer={
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<Button variant="link" size="inline" asChild>
<a href="/settings/tools">
<Link to="/settings/tools">
{t('settings.tools.manageTools')}
<ArrowRight aria-hidden="true" className="size-3" />
</a>
</Link>
</Button>
<Button variant="link" size="inline" asChild>
<a href="/settings/connectors?capability=tools">
<Link to="/settings/connectors?capability=tools">
{t('conversation.sources.connectMore')}
<ArrowRight aria-hidden="true" className="size-3" />
</a>
</Link>
</Button>
</div>
}
+8 -16
View File
@@ -26,22 +26,14 @@ vi.mock('../api/services/connectorsService', () => ({ default: service }));
// The OAuth popup is covered by ConnectorAuth's own tests; here it only has
// to report a finished sign-in.
vi.mock('../components/ConnectorAuth', () => ({
default: ({
onSuccess,
label,
}: {
onSuccess: (data: { connection_id: string; user_email: string }) => void;
label: string;
}) => (
<button
type="button"
onClick={() =>
onSuccess({ connection_id: 'conn-drive', user_email: 'a@example.com' })
}
>
{label}
</button>
),
useConnectorAuth:
({
onSuccess,
}: {
onSuccess: (data: { connection_id: string; user_email: string }) => void;
}) =>
() =>
onSuccess({ connection_id: 'conn-drive', user_email: 'a@example.com' }),
}));
vi.mock('../components/FilePicker', () => ({
FilePicker: ({
+25 -16
View File
@@ -7,7 +7,7 @@ import { useNavigate } from 'react-router-dom';
import { envVar } from '@/env';
import connectorsService from '../api/services/connectorsService';
import ConnectorAuth from '../components/ConnectorAuth';
import { useConnectorAuth } from '../components/ConnectorAuth';
import { FilePicker } from '../components/FilePicker';
import GoogleDrivePicker from '../components/GoogleDrivePicker';
import {
@@ -37,6 +37,7 @@ import {
setSelectedDocs,
} from '../preferences/preferenceSlice';
import type { AppDispatch } from '../store';
import { formatCount } from '../utils/dateTimeUtils';
import ConnectorIcon from './ConnectorIcon';
import CredentialForm, { credentialsComplete } from './CredentialForm';
import { loadConnectors, selectConnections } from './connectorsSlice';
@@ -134,6 +135,14 @@ export default function ConnectWizard({
setStep(canSync ? 'setup' : 'done');
};
const startSignIn = useConnectorAuth({
provider: connector.key,
connectionId:
mode === 'reconnect' ? (connectionId ?? undefined) : undefined,
onSuccess: (data) => afterSignIn(data.connection_id),
onError: setError,
});
const submitCredentials = async () => {
setPending(true);
setError('');
@@ -253,9 +262,11 @@ export default function ConnectWizard({
});
const sourcesText = t('settings.connectors.wizard.sourcesCount', {
count: sources.length,
formatted: formatCount(sources.length),
});
const toolsText = t('settings.connectors.wizard.toolsCount', {
count: toolCount,
formatted: formatCount(toolCount),
});
const countsLine =
sources.length && toolCount
@@ -289,20 +300,7 @@ export default function ConnectWizard({
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{connector.auth_kind === 'oauth' ? (
<ConnectorAuth
provider={connector.key}
connectionId={
mode === 'reconnect' ? (connectionId ?? undefined) : undefined
}
label={t('settings.connectors.wizard.signIn', {
name,
interpolation: { escapeValue: false },
})}
onSuccess={(data) => afterSignIn(data.connection_id)}
onError={setError}
/>
) : (
{connector.auth_kind === 'oauth' ? null : (
<CredentialForm
connectorKey={connector.key}
idPrefix={`connect-${connector.key}`}
@@ -393,6 +391,7 @@ export default function ConnectWizard({
<AccordionTrigger>
{t('settings.connectors.wizard.toolsHeading', {
count: toolCount,
formatted: formatCount(toolCount),
})}
</AccordionTrigger>
<AccordionContent>
@@ -433,7 +432,17 @@ export default function ConnectWizard({
const footer =
step === 'signin' ? (
connector.auth_kind === 'oauth' ? undefined : (
connector.auth_kind === 'oauth' ? (
<ModalActions
cancelLabel={t('cancel')}
onCancel={onClose}
submitLabel={t('settings.connectors.wizard.signIn', {
name,
interpolation: { escapeValue: false },
})}
onSubmit={startSignIn}
/>
) : (
<ModalActions
cancelLabel={t('cancel')}
onCancel={onClose}
+67 -70
View File
@@ -1,10 +1,10 @@
import {
CircleAlert,
ExternalLink,
Plus,
RefreshCw,
RotateCw,
Trash2,
TriangleAlert,
Unplug,
} from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
@@ -12,7 +12,7 @@ import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import connectorsService from '../api/services/connectorsService';
import { Alert, AlertDescription, AlertTitle } from '../components/ui/alert';
import { Alert, AlertDescription } from '../components/ui/alert';
import { Badge } from '../components/ui/badge';
import { Button } from '../components/ui/button';
import { Card } from '../components/ui/card';
@@ -23,15 +23,21 @@ import { ListRow, ListRows } from '../components/ui/list-row';
import { LoadingState } from '../components/ui/loading-state';
import { Modal, ModalActions } from '../components/ui/modal';
import { SectionHeader } from '../components/ui/section-header';
import { Sheet, SheetContent, SheetTitle } from '../components/ui/sheet';
import {
Sheet,
SheetContent,
SheetDescription,
SheetTitle,
} from '../components/ui/sheet';
import { ToggleGroup, ToggleGroupItem } from '../components/ui/toggle-group';
import ConfirmationModal from '../modals/ConfirmationModal';
import { showActionToast } from '../notifications/actionToastSlice';
import { selectToken } from '../preferences/preferenceSlice';
import type { AppDispatch } from '../store';
import { formatDateTime } from '../utils/dateTimeUtils';
import { formatCount, formatDateTime } from '../utils/dateTimeUtils';
import { CapabilityBadges } from './ConnectorCard';
import ConnectorIcon from './ConnectorIcon';
import ConnectorSetupNotice from './ConnectorSetupNotice';
import { loadConnectors, selectConnections } from './connectorsSlice';
import { connectorDescription, connectorName } from './i18n';
import ToolPermissions from './ToolPermissions';
@@ -133,6 +139,7 @@ function RemoveConnectionModal({
float={false}
label={t('settings.connectors.remove.sourcesLabel', {
count: detail.sources.length,
formatted: formatCount(detail.sources.length),
})}
>
<ToggleGroup
@@ -156,6 +163,7 @@ function RemoveConnectionModal({
float={false}
label={t('settings.connectors.remove.toolsLabel', {
count: detail.tools.length,
formatted: formatCount(detail.tools.length),
})}
>
<ToggleGroup
@@ -219,7 +227,7 @@ function AccountSection({
return (
<div className="flex flex-col gap-6">
<Card padding="none">
<Card variant="subtle" padding="none">
<ListRows>
<ListRow
title={accountTitle(detail)}
@@ -236,7 +244,8 @@ function AccountSection({
{detail.status !== 'connected' && (
<Button
type="button"
size="xs"
size="sm"
shape="pill"
variant="outline"
onClick={() => onReconnect(detail)}
>
@@ -275,11 +284,13 @@ function AccountSection({
}
/>
{detail.sources.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t('settings.connectors.detail.noSources')}
</p>
<EmptyState
size="xs"
illustration="none"
title={t('settings.connectors.detail.noSources')}
/>
) : (
<Card padding="none">
<Card variant="subtle" padding="none">
<ListRows>
{detail.sources.map((source) => (
<ListRow
@@ -338,17 +349,23 @@ function AccountSection({
/>
{detail.tools.map((tool) => (
<div key={tool.id} className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-3">
<span className="text-foreground min-w-0 truncate text-sm font-medium">
{tool.display_name}
</span>
<Badge variant={tool.status ? 'success' : 'neutral'}>
{tool.status
? t('settings.connectors.detail.toolOn')
: t('settings.connectors.detail.toolOff')}
</Badge>
</div>
<ToolPermissions connectionId={detail.id} tool={tool} />
<SectionHeader
as="h4"
size="xs"
title={tool.display_name}
actions={
<Badge variant={tool.status ? 'success' : 'neutral'}>
{tool.status
? t('settings.connectors.detail.toolOn')
: t('settings.connectors.detail.toolOff')}
</Badge>
}
/>
<ToolPermissions
connectionId={detail.id}
tool={tool}
variant="subtle"
/>
</div>
))}
</section>
@@ -420,10 +437,23 @@ export default function ConnectionDrawer({
const confirmDisconnect = () => {
if (!toDisconnect) return;
connectorsService.disconnect(toDisconnect.id, token).finally(() => {
setToDisconnect(null);
refresh();
});
connectorsService
.disconnect(toDisconnect.id, token)
.then((data) => {
if (!data?.success) throw new Error('disconnect failed');
})
.catch(() =>
dispatch(
showActionToast({
variant: 'destructive',
message: t('settings.connectors.disconnect.failed'),
}),
),
)
.finally(() => {
setToDisconnect(null);
refresh();
});
};
const reconnect = (detail: ConnectionDetail) => {
@@ -452,8 +482,8 @@ export default function ConnectionDrawer({
? {
variant: 'success',
message: t('settings.connectors.detail.refreshed', {
added: data.added?.length ?? 0,
removed: data.removed?.length ?? 0,
added: formatCount(data.added?.length ?? 0),
removed: formatCount(data.removed?.length ?? 0),
}),
}
: {
@@ -474,11 +504,12 @@ export default function ConnectionDrawer({
<SheetContent
side="right"
size="detail"
aria-describedby={undefined}
closeLabel={t('agents.close')}
className="overflow-y-auto"
>
<div className="flex flex-col gap-6 p-6">
<div className="flex items-start gap-4 pr-8">
{/* pr-12 keeps the header clear of the close X. */}
<div className="flex items-start gap-4 pr-12">
<span className="bg-muted flex size-12 shrink-0 items-center justify-center rounded-xl">
<ConnectorIcon icon={connector.icon} className="size-7" />
</span>
@@ -489,14 +520,14 @@ export default function ConnectionDrawer({
</p>
</div>
</div>
<p className="text-muted-foreground text-sm">
<SheetDescription>
{connectorDescription(t, connector)}
</p>
</SheetDescription>
<CapabilityBadges capabilities={connector.capabilities} />
{connector.publisher === 'custom' && (
<Alert variant="warning" role="note">
<CircleAlert />
<TriangleAlert />
<AlertDescription>
{t('settings.connectors.unverified')}
</AlertDescription>
@@ -504,43 +535,7 @@ export default function ConnectionDrawer({
)}
{connector.needs_setup && (
<Alert variant="warning">
<CircleAlert />
<AlertTitle>
{t('settings.connectors.status.needsAdminSetup')}
</AlertTitle>
<AlertDescription>
<div className="flex flex-col gap-2">
{connector.missing_settings.length > 0 ? (
<>
<span>{t('settings.connectors.setupSettings')}</span>
<code className="font-mono text-xs wrap-anywhere">
{connector.missing_settings.join(', ')}
</code>
</>
) : (
<span>{t('settings.connectors.askAdmin')}</span>
)}
{connector.docs_url && (
<Button
variant="link"
size="inline"
asChild
className="w-fit"
>
<a
href={connector.docs_url}
target="_blank"
rel="noopener noreferrer"
>
{t('settings.connectors.setupGuide')}
<ExternalLink />
</a>
</Button>
)}
</div>
</AlertDescription>
</Alert>
<ConnectorSetupNotice connector={connector} />
)}
<section className="flex flex-col gap-3">
@@ -575,6 +570,7 @@ export default function ConnectionDrawer({
<Button
variant="outline"
size="sm"
shape="pill"
onClick={() => setReloadKey((key) => key + 1)}
>
{t('retry')}
@@ -599,7 +595,7 @@ export default function ConnectionDrawer({
}
/>
) : (
<div className="flex flex-col gap-8">
<div className="flex flex-col gap-6">
{details.map((detail) => (
<AccountSection
key={detail.id}
@@ -630,6 +626,7 @@ export default function ConnectionDrawer({
})}
description={t('settings.connectors.disconnect.body', {
count: toDisconnect?.source_count ?? 0,
formatted: formatCount(toDisconnect?.source_count ?? 0),
})}
modalState={toDisconnect ? 'ACTIVE' : 'INACTIVE'}
setModalState={(state) => state === 'INACTIVE' && setToDisconnect(null)}
+5 -11
View File
@@ -8,20 +8,11 @@ import {
CardFooter,
CardTitle,
} from '../components/ui/card';
import { formatCount } from '../utils/dateTimeUtils';
import ConnectorIcon from './ConnectorIcon';
import { connectorDescription, connectorName } from './i18n';
import type { Capability, ConnectorDefinition } from './types';
// Sync is blue, Read green, Write red: what the connection does to your data.
const CAPABILITY_VARIANT: Record<
Capability,
'info' | 'success' | 'destructive'
> = {
sync: 'info',
read: 'success',
write: 'destructive',
};
export function CapabilityBadges({
capabilities,
}: {
@@ -31,7 +22,9 @@ export function CapabilityBadges({
return (
<div className="flex flex-wrap gap-1">
{capabilities.map((capability) => (
<Badge key={capability} variant={CAPABILITY_VARIANT[capability]}>
// Capabilities are categories, not states: neutral, so the status
// hues stay free for the state badge (DESIGN.md "Colour tokens").
<Badge key={capability} variant="neutral">
{t(`settings.connectors.capability.${capability}`)}
</Badge>
))}
@@ -53,6 +46,7 @@ export function ConnectorStateBadge({
{connector.connected_count > 1
? t('settings.connectors.status.connectedCount', {
count: connector.connected_count,
formatted: formatCount(connector.connected_count),
})
: t('settings.connectors.status.connected')}
</Badge>
@@ -0,0 +1,57 @@
import { ExternalLink, TriangleAlert } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Alert, AlertDescription, AlertTitle } from '../components/ui/alert';
import { Button } from '../components/ui/button';
import type { ConnectorDefinition } from './types';
/**
* "Needs admin setup" for a connector that still lacks server settings:
* the missing setting names for an admin, "ask your admin" for everyone
* else, and the setup guide when there is one.
*/
export default function ConnectorSetupNotice({
connector,
}: {
connector: Pick<ConnectorDefinition, 'missing_settings' | 'docs_url'>;
}) {
const { t } = useTranslation();
return (
<Alert variant="warning">
<TriangleAlert />
<AlertTitle>{t('settings.connectors.status.needsAdminSetup')}</AlertTitle>
<AlertDescription>
<div className="flex flex-col gap-2">
{connector.missing_settings.length > 0 ? (
<>
<span>{t('settings.connectors.setupSettings')}</span>
<code className="font-mono text-xs wrap-anywhere">
{connector.missing_settings.join(', ')}
</code>
</>
) : (
<span>{t('settings.connectors.askAdmin')}</span>
)}
{connector.docs_url && (
<Button
variant="link"
size="inline"
asChild
// eslint-disable-next-line shadcn/no-restyle -- the link inherits its Alert's status colour
className="w-fit text-current"
>
<a
href={connector.docs_url}
target="_blank"
rel="noopener noreferrer"
>
{t('settings.connectors.setupGuide')}
<ExternalLink />
</a>
</Button>
)}
</div>
</AlertDescription>
</Alert>
);
}
+17 -4
View File
@@ -1,6 +1,6 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import connectorsService from '../api/services/connectorsService';
import { Card } from '../components/ui/card';
@@ -12,6 +12,7 @@ import {
SelectTrigger,
SelectValue,
} from '../components/ui/select';
import { showActionToast } from '../notifications/actionToastSlice';
import { selectToken } from '../preferences/preferenceSlice';
import type {
ActionPermission,
@@ -24,20 +25,24 @@ const PERMISSIONS: ActionPermission[] = ['always', 'ask', 'off'];
/**
* One tool's actions, grouped Read and Write, each with Always allow /
* Needs approval / Off. Changes save immediately; a failed save puts the
* previous choice back.
* previous choice back and says so.
*/
export default function ToolPermissions({
connectionId,
tool,
onChange,
readOnly = false,
variant = 'outline',
}: {
connectionId: string;
tool: ConnectionTool;
onChange?: (tool: ConnectionTool) => void;
readOnly?: boolean;
/** The panel surface: `outline` in a modal, `subtle` in the drawer. */
variant?: 'outline' | 'subtle';
}) {
const { t } = useTranslation();
const dispatch = useDispatch();
const token = useSelector(selectToken);
const [actions, setActions] = useState<ConnectionToolAction[]>(tool.actions);
@@ -53,7 +58,15 @@ export default function ToolPermissions({
if (!data?.success) throw new Error('save failed');
onChange?.(data.tool);
})
.catch(() => setActions(previous));
.catch(() => {
setActions(previous);
dispatch(
showActionToast({
variant: 'destructive',
message: t('settings.connectors.permission.saveFailed'),
}),
);
});
};
const groups = (['read', 'write'] as const)
@@ -64,7 +77,7 @@ export default function ToolPermissions({
.filter((group) => group.actions.length > 0);
return (
<Card padding="sm" className="gap-4">
<Card variant={variant} padding="sm" className="gap-4">
{groups.map((group) => (
<div key={group.access} className="flex flex-col gap-2">
<SectionHeader
@@ -1,4 +1,5 @@
import { useCallback, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { useNavigate } from 'react-router-dom';
@@ -7,6 +8,7 @@ import userService from '../api/services/userService';
import MCPServerModal from '../modals/MCPServerModal';
import type { AvailableToolType } from '../modals/types';
import type { ActiveState } from '../models/misc';
import { showActionToast } from '../notifications/actionToastSlice';
import { selectToken } from '../preferences/preferenceSlice';
import type { AppDispatch } from '../store';
import ConnectWizard, { type WizardMode } from './ConnectWizard';
@@ -46,6 +48,7 @@ const isMcp = (connector: ConnectorDefinition) =>
export default function useConnectorLauncher({
onConnected,
}: { onConnected?: () => void } = {}) {
const { t } = useTranslation();
const dispatch = useDispatch<AppDispatch>();
const navigate = useNavigate();
const token = useSelector(selectToken);
@@ -80,26 +83,34 @@ export default function useConnectorLauncher({
if (connector.key === 'custom_openapi') {
// An OpenAPI tool starts empty; its spec import is today's API Tool
// screen on the Tools page.
const response = await userService.getAvailableTools(token);
const data = await response.json();
const tool = (data.data as AvailableToolType[] | undefined)?.find(
(candidate) => candidate.name === 'api_tool',
);
if (!tool) return;
const created = await userService.createTool(
{
name: tool.name,
displayName: tool.displayName,
description: tool.description,
config: {},
actions: tool.actions,
status: true,
},
token,
);
const body = await created.json();
if (body?.id) {
try {
const response = await userService.getAvailableTools(token);
const data = await response.json();
const tool = (data.data as AvailableToolType[] | undefined)?.find(
(candidate) => candidate.name === 'api_tool',
);
if (!tool) throw new Error('api_tool is not available');
const created = await userService.createTool(
{
name: tool.name,
displayName: tool.displayName,
description: tool.description,
config: {},
actions: tool.actions,
status: true,
},
token,
);
const body = await created.json();
if (!body?.id) throw new Error('create failed');
navigate('/settings/tools', { state: { openToolId: body.id } });
} catch {
dispatch(
showActionToast({
variant: 'destructive',
message: t('settings.connectors.createFailed'),
}),
);
}
return;
}
@@ -110,7 +121,7 @@ export default function useConnectorLauncher({
connectionId: options.connectionId,
});
},
[navigate, token],
[dispatch, navigate, t, token],
);
const afterMcpSave = async () => {
+18 -10
View File
@@ -1120,7 +1120,7 @@
"status": {
"connect": "Verbinden",
"connected": "Verbunden",
"connectedCount": "{{count}} verbunden",
"connectedCount": "{{formatted}} verbunden",
"reconnect": "Neu verbinden",
"needsAdminSetup": "Admin-Einrichtung nötig",
"disabledByAdmin": "Vom Admin deaktiviert",
@@ -1190,13 +1190,15 @@
"always": "Immer erlauben",
"ask": "Freigabe nötig",
"off": "Aus",
"label": "Berechtigung für {{action}}"
"label": "Berechtigung für {{action}}",
"saveFailed": "Die Berechtigung konnte nicht gespeichert werden. Versuche es erneut."
},
"disconnect": {
"title": "{{name}} trennen?",
"body_zero": "Werkzeuge aus dieser Verbindung funktionieren erst wieder, wenn du neu verbindest.",
"body_one": "Deine Quelle behält ihren Inhalt, wird aber nicht mehr synchronisiert. Werkzeuge aus dieser Verbindung funktionieren erst wieder, wenn du neu verbindest.",
"body_other": "Deine {{count}} Quellen behalten ihren Inhalt, werden aber nicht mehr synchronisiert. Werkzeuge aus dieser Verbindung funktionieren erst wieder, wenn du neu verbindest."
"body_other": "Deine {{formatted}} Quellen behalten ihren Inhalt, werden aber nicht mehr synchronisiert. Werkzeuge aus dieser Verbindung funktionieren erst wieder, wenn du neu verbindest.",
"failed": "Trennen fehlgeschlagen. Versuche es erneut."
},
"fields": {
"token": "API-Schlüssel",
@@ -1226,11 +1228,11 @@
"doneTitle": "{{name}} ist verbunden",
"doneSummary": "Verbunden als {{account}}. {{sources}} werden synchronisiert, {{tools}} bereit.",
"doneSummaryNone": "Verbunden als {{account}}.",
"sourcesCount_one": "{{count}} Quelle",
"sourcesCount_other": "{{count}} Quellen",
"toolsCount_one": "{{count}} Werkzeug",
"toolsCount_other": "{{count}} Werkzeuge",
"toolsHeading": "Werkzeuge ({{count}})",
"sourcesCount_one": "{{formatted}} Quelle",
"sourcesCount_other": "{{formatted}} Quellen",
"toolsCount_one": "{{formatted}} Werkzeug",
"toolsCount_other": "{{formatted}} Werkzeuge",
"toolsHeading": "Werkzeuge ({{formatted}})",
"tryInChat": "Im Chat ausprobieren",
"done": "Fertig",
"connectFailed": "Verbindung fehlgeschlagen. Prüfe die Angaben und versuche es erneut.",
@@ -1244,11 +1246,11 @@
"title": "{{name}} entfernen?",
"description": "Die gespeicherte Anmeldung wird gelöscht. Wähle, was mit den Inhalten dieser Verbindung passiert.",
"sourcesLabel_one": "Ihre Quelle",
"sourcesLabel_other": "Ihre {{count}} Quellen",
"sourcesLabel_other": "Ihre {{formatted}} Quellen",
"keepSources": "Inhalt behalten (Sync stoppen)",
"deleteSources": "Löschen",
"toolsLabel_one": "Ihr Werkzeug",
"toolsLabel_other": "Ihre {{count}} Werkzeuge",
"toolsLabel_other": "Ihre {{formatted}} Werkzeuge",
"deleteTools": "Löschen",
"keepTools": "Behalten, ausgeschaltet",
"failed": "Die Verbindung konnte nicht entfernt werden. Versuche es erneut."
@@ -1271,6 +1273,12 @@
"memberNote": "Jedes Mitglied verbindet sein eigenes {{name}}-Konto, bevor es dieses Werkzeug nutzen kann.",
"forced": "Ein Admin hat das für alle Freigaben dieses Konnektors festgelegt.",
"saveFailed": "Die Änderung konnte nicht gespeichert werden. Versuche es erneut."
},
"createFailed": "Der Konnektor konnte nicht hinzugefügt werden. Versuche es erneut.",
"capabilityFilter": {
"sync": "Angezeigt werden Konnektoren, die Inhalte in Quellen synchronisieren.",
"tools": "Angezeigt werden Konnektoren, die Agenten Werkzeuge geben.",
"showAll": "Alle anzeigen"
}
}
},
+18 -13
View File
@@ -1126,7 +1126,7 @@
"status": {
"connect": "Connect",
"connected": "Connected",
"connectedCount": "{{count}} connected",
"connectedCount": "{{formatted}} connected",
"reconnect": "Reconnect",
"needsAdminSetup": "Needs admin setup",
"disabledByAdmin": "Disabled by admin",
@@ -1196,13 +1196,15 @@
"always": "Always allow",
"ask": "Needs approval",
"off": "Off",
"label": "Permission for {{action}}"
"label": "Permission for {{action}}",
"saveFailed": "Could not save the permission. Try again."
},
"disconnect": {
"title": "Disconnect {{name}}?",
"body_zero": "Tools from this connection stop working until you reconnect.",
"body_one": "Your source keeps its content but stops syncing. Tools from this connection stop working until you reconnect.",
"body_other": "Your {{count}} sources keep their content but stop syncing. Tools from this connection stop working until you reconnect."
"body_other": "Your {{formatted}} sources keep their content but stop syncing. Tools from this connection stop working until you reconnect.",
"failed": "Could not disconnect. Try again."
},
"fields": {
"token": "API key",
@@ -1232,11 +1234,11 @@
"doneTitle": "{{name}} is connected",
"doneSummary": "Connected as {{account}}. {{sources}} syncing, {{tools}} ready.",
"doneSummaryNone": "Connected as {{account}}.",
"sourcesCount_one": "{{count}} source",
"sourcesCount_other": "{{count}} sources",
"toolsCount_one": "{{count}} tool",
"toolsCount_other": "{{count}} tools",
"toolsHeading": "Tools ({{count}})",
"sourcesCount_one": "{{formatted}} source",
"sourcesCount_other": "{{formatted}} sources",
"toolsCount_one": "{{formatted}} tool",
"toolsCount_other": "{{formatted}} tools",
"toolsHeading": "Tools ({{formatted}})",
"tryInChat": "Try it in chat",
"done": "Done",
"connectFailed": "Could not connect. Check the details and try again.",
@@ -1250,11 +1252,11 @@
"title": "Remove {{name}}?",
"description": "The stored sign-in is deleted. Choose what happens to what this connection feeds.",
"sourcesLabel_one": "Its source",
"sourcesLabel_other": "Its {{count}} sources",
"sourcesLabel_other": "Its {{formatted}} sources",
"keepSources": "Keep content (stop syncing)",
"deleteSources": "Delete",
"toolsLabel_one": "Its tool",
"toolsLabel_other": "Its {{count}} tools",
"toolsLabel_other": "Its {{formatted}} tools",
"deleteTools": "Delete",
"keepTools": "Keep, turned off",
"failed": "Could not remove the connection. Try again."
@@ -1277,6 +1279,12 @@
"memberNote": "Each member connects their own {{name}} account before they can use this tool.",
"forced": "An admin chose this for every share of this connector.",
"saveFailed": "Could not save the change. Try again."
},
"createFailed": "Could not add the connector. Try again.",
"capabilityFilter": {
"sync": "Showing connectors that sync content into Sources.",
"tools": "Showing connectors that give agents tools.",
"showAll": "Show all"
}
}
},
@@ -2616,9 +2624,6 @@
"body": "These were used this {{period}} and recorded at $0: {{models}}. Use a token limit for them, or declare their rates in the model catalog.",
"model": "{{model}} ({{tokens}} tokens)"
}
},
"connectors": {
"defaultKey": "Set ENCRYPTION_SECRET_KEY before connecting services."
}
}
}
+18 -10
View File
@@ -1120,7 +1120,7 @@
"status": {
"connect": "Conectar",
"connected": "Conectado",
"connectedCount": "{{count}} conectadas",
"connectedCount": "{{formatted}} conectadas",
"reconnect": "Reconectar",
"needsAdminSetup": "Requiere configuración del administrador",
"disabledByAdmin": "Desactivado por el administrador",
@@ -1190,13 +1190,15 @@
"always": "Permitir siempre",
"ask": "Requiere aprobación",
"off": "Desactivada",
"label": "Permiso para {{action}}"
"label": "Permiso para {{action}}",
"saveFailed": "No se pudo guardar el permiso. Inténtalo de nuevo."
},
"disconnect": {
"title": "¿Desconectar {{name}}?",
"body_zero": "Las herramientas de esta conexión dejarán de funcionar hasta que vuelvas a conectarla.",
"body_one": "Tu fuente conserva su contenido pero deja de sincronizarse. Las herramientas de esta conexión dejarán de funcionar hasta que vuelvas a conectarla.",
"body_other": "Tus {{count}} fuentes conservan su contenido pero dejan de sincronizarse. Las herramientas de esta conexión dejarán de funcionar hasta que vuelvas a conectarla."
"body_other": "Tus {{formatted}} fuentes conservan su contenido pero dejan de sincronizarse. Las herramientas de esta conexión dejarán de funcionar hasta que vuelvas a conectarla.",
"failed": "No se pudo desconectar. Inténtalo de nuevo."
},
"fields": {
"token": "Clave de API",
@@ -1226,11 +1228,11 @@
"doneTitle": "{{name}} está conectado",
"doneSummary": "Conectado como {{account}}. {{sources}} sincronizando, {{tools}} listas.",
"doneSummaryNone": "Conectado como {{account}}.",
"sourcesCount_one": "{{count}} fuente",
"sourcesCount_other": "{{count}} fuentes",
"toolsCount_one": "{{count}} herramienta",
"toolsCount_other": "{{count}} herramientas",
"toolsHeading": "Herramientas ({{count}})",
"sourcesCount_one": "{{formatted}} fuente",
"sourcesCount_other": "{{formatted}} fuentes",
"toolsCount_one": "{{formatted}} herramienta",
"toolsCount_other": "{{formatted}} herramientas",
"toolsHeading": "Herramientas ({{formatted}})",
"tryInChat": "Probar en el chat",
"done": "Listo",
"connectFailed": "No se pudo conectar. Revisa los datos e inténtalo de nuevo.",
@@ -1244,11 +1246,11 @@
"title": "¿Eliminar {{name}}?",
"description": "Se borra el inicio de sesión guardado. Elige qué pasa con lo que alimenta esta conexión.",
"sourcesLabel_one": "Su fuente",
"sourcesLabel_other": "Sus {{count}} fuentes",
"sourcesLabel_other": "Sus {{formatted}} fuentes",
"keepSources": "Conservar el contenido (dejar de sincronizar)",
"deleteSources": "Eliminar",
"toolsLabel_one": "Su herramienta",
"toolsLabel_other": "Sus {{count}} herramientas",
"toolsLabel_other": "Sus {{formatted}} herramientas",
"deleteTools": "Eliminar",
"keepTools": "Conservar, desactivadas",
"failed": "No se pudo eliminar la conexión. Inténtalo de nuevo."
@@ -1271,6 +1273,12 @@
"memberNote": "Cada miembro conecta su propia cuenta de {{name}} antes de poder usar esta herramienta.",
"forced": "Un administrador eligió esto para todos los recursos compartidos de este conector.",
"saveFailed": "No se pudo guardar el cambio. Inténtalo de nuevo."
},
"createFailed": "No se pudo añadir el conector. Inténtalo de nuevo.",
"capabilityFilter": {
"sync": "Se muestran los conectores que sincronizan contenido en Fuentes.",
"tools": "Se muestran los conectores que dan herramientas a los agentes.",
"showAll": "Mostrar todos"
}
}
},
+16 -8
View File
@@ -1111,7 +1111,7 @@
"status": {
"connect": "接続",
"connected": "接続済み",
"connectedCount": "{{count}} 件接続済み",
"connectedCount": "{{formatted}} 件接続済み",
"reconnect": "再接続",
"needsAdminSetup": "管理者の設定が必要",
"disabledByAdmin": "管理者により無効",
@@ -1181,12 +1181,14 @@
"always": "常に許可",
"ask": "承認が必要",
"off": "オフ",
"label": "{{action}} の権限"
"label": "{{action}} の権限",
"saveFailed": "権限を保存できませんでした。もう一度お試しください。"
},
"disconnect": {
"title": "{{name}} を切断しますか?",
"body_zero": "この接続のツールは、再接続するまで使えなくなります。",
"body_other": "{{count}} 件のソースはコンテンツを保持しますが、同期は停止します。この接続のツールは、再接続するまで使えなくなります。"
"body_other": "{{formatted}} 件のソースはコンテンツを保持しますが、同期は停止します。この接続のツールは、再接続するまで使えなくなります。",
"failed": "切断できませんでした。もう一度お試しください。"
},
"fields": {
"token": "API キー",
@@ -1216,9 +1218,9 @@
"doneTitle": "{{name}} に接続しました",
"doneSummary": "{{account}} として接続しました。{{sources}}を同期中、{{tools}}を利用できます。",
"doneSummaryNone": "{{account}} として接続しました。",
"sourcesCount_other": "{{count}} 件のソース",
"toolsCount_other": "{{count}} 個のツール",
"toolsHeading": "ツール ({{count}})",
"sourcesCount_other": "{{formatted}} 件のソース",
"toolsCount_other": "{{formatted}} 個のツール",
"toolsHeading": "ツール ({{formatted}})",
"tryInChat": "チャットで試す",
"done": "完了",
"connectFailed": "接続できませんでした。入力内容を確認して、もう一度お試しください。",
@@ -1231,10 +1233,10 @@
"remove": {
"title": "{{name}} を削除しますか?",
"description": "保存されたサインイン情報は削除されます。この接続から提供されているものをどうするか選んでください。",
"sourcesLabel_other": "{{count}} 件のソース",
"sourcesLabel_other": "{{formatted}} 件のソース",
"keepSources": "コンテンツを保持(同期を停止)",
"deleteSources": "削除",
"toolsLabel_other": "{{count}} 個のツール",
"toolsLabel_other": "{{formatted}} 個のツール",
"deleteTools": "削除",
"keepTools": "保持してオフにする",
"failed": "接続を削除できませんでした。もう一度お試しください。"
@@ -1257,6 +1259,12 @@
"memberNote": "各メンバーは、このツールを使う前に自分の {{name}} アカウントを接続します。",
"forced": "このコネクタのすべての共有に対して管理者が設定しています。",
"saveFailed": "変更を保存できませんでした。もう一度お試しください。"
},
"createFailed": "コネクタを追加できませんでした。もう一度お試しください。",
"capabilityFilter": {
"sync": "ソースにコンテンツを同期するコネクタを表示しています。",
"tools": "エージェントにツールを提供するコネクタを表示しています。",
"showAll": "すべて表示"
}
}
},
+29 -21
View File
@@ -1176,7 +1176,7 @@
"status": {
"connect": "Подключить",
"connected": "Подключено",
"connectedCount": "Подключено: {{count}}",
"connectedCount": "Подключено: {{formatted}}",
"reconnect": "Переподключить",
"needsAdminSetup": "Нужна настройка администратором",
"disabledByAdmin": "Отключено администратором",
@@ -1246,15 +1246,17 @@
"always": "Всегда разрешать",
"ask": "Нужно одобрение",
"off": "Выкл.",
"label": "Разрешение для {{action}}"
"label": "Разрешение для {{action}}",
"saveFailed": "Не удалось сохранить разрешение. Попробуйте ещё раз."
},
"disconnect": {
"title": "Отключить {{name}}?",
"body_zero": "Инструменты из этого подключения перестанут работать, пока вы не переподключитесь.",
"body_one": "Ваш {{count}} источник сохранит контент, но перестанет синхронизироваться. Инструменты из этого подключения перестанут работать, пока вы не переподключитесь.",
"body_few": "Ваши {{count}} источника сохранят контент, но перестанут синхронизироваться. Инструменты из этого подключения перестанут работать, пока вы не переподключитесь.",
"body_many": "Ваши {{count}} источников сохранят контент, но перестанут синхронизироваться. Инструменты из этого подключения перестанут работать, пока вы не переподключитесь.",
"body_other": "Ваши {{count}} источника сохранят контент, но перестанут синхронизироваться. Инструменты из этого подключения перестанут работать, пока вы не переподключитесь."
"body_one": "Ваш {{formatted}} источник сохранит контент, но перестанет синхронизироваться. Инструменты из этого подключения перестанут работать, пока вы не переподключитесь.",
"body_few": "Ваши {{formatted}} источника сохранят контент, но перестанут синхронизироваться. Инструменты из этого подключения перестанут работать, пока вы не переподключитесь.",
"body_many": "Ваши {{formatted}} источников сохранят контент, но перестанут синхронизироваться. Инструменты из этого подключения перестанут работать, пока вы не переподключитесь.",
"body_other": "Ваши {{formatted}} источника сохранят контент, но перестанут синхронизироваться. Инструменты из этого подключения перестанут работать, пока вы не переподключитесь.",
"failed": "Не удалось отключить. Попробуйте ещё раз."
},
"fields": {
"token": "API-ключ",
@@ -1284,15 +1286,15 @@
"doneTitle": "{{name}} подключён",
"doneSummary": "Подключено как {{account}}. Синхронизируется: {{sources}}, готово: {{tools}}.",
"doneSummaryNone": "Подключено как {{account}}.",
"sourcesCount_one": "{{count}} источник",
"sourcesCount_few": "{{count}} источника",
"sourcesCount_many": "{{count}} источников",
"sourcesCount_other": "{{count}} источника",
"toolsCount_one": "{{count}} инструмент",
"toolsCount_few": "{{count}} инструмента",
"toolsCount_many": "{{count}} инструментов",
"toolsCount_other": "{{count}} инструмента",
"toolsHeading": "Инструменты ({{count}})",
"sourcesCount_one": "{{formatted}} источник",
"sourcesCount_few": "{{formatted}} источника",
"sourcesCount_many": "{{formatted}} источников",
"sourcesCount_other": "{{formatted}} источника",
"toolsCount_one": "{{formatted}} инструмент",
"toolsCount_few": "{{formatted}} инструмента",
"toolsCount_many": "{{formatted}} инструментов",
"toolsCount_other": "{{formatted}} инструмента",
"toolsHeading": "Инструменты ({{formatted}})",
"tryInChat": "Попробовать в чате",
"done": "Готово",
"connectFailed": "Не удалось подключиться. Проверьте данные и попробуйте ещё раз.",
@@ -1306,15 +1308,15 @@
"title": "Удалить {{name}}?",
"description": "Сохранённый вход будет удалён. Выберите, что станет с тем, что даёт это подключение.",
"sourcesLabel_one": "Его источник",
"sourcesLabel_few": "Его {{count}} источника",
"sourcesLabel_many": "Его {{count}} источников",
"sourcesLabel_other": "Его {{count}} источника",
"sourcesLabel_few": "Его {{formatted}} источника",
"sourcesLabel_many": "Его {{formatted}} источников",
"sourcesLabel_other": "Его {{formatted}} источника",
"keepSources": "Оставить контент (остановить синхронизацию)",
"deleteSources": "Удалить",
"toolsLabel_one": "Его инструмент",
"toolsLabel_few": "Его {{count}} инструмента",
"toolsLabel_many": "Его {{count}} инструментов",
"toolsLabel_other": "Его {{count}} инструмента",
"toolsLabel_few": "Его {{formatted}} инструмента",
"toolsLabel_many": "Его {{formatted}} инструментов",
"toolsLabel_other": "Его {{formatted}} инструмента",
"deleteTools": "Удалить",
"keepTools": "Оставить выключенными",
"failed": "Не удалось удалить подключение. Попробуйте ещё раз."
@@ -1337,6 +1339,12 @@
"memberNote": "Каждый участник подключает свой аккаунт {{name}}, прежде чем пользоваться этим инструментом.",
"forced": "Администратор задал это для всех общих доступов к этому коннектору.",
"saveFailed": "Не удалось сохранить изменение. Попробуйте ещё раз."
},
"createFailed": "Не удалось добавить коннектор. Попробуйте ещё раз.",
"capabilityFilter": {
"sync": "Показаны коннекторы, которые синхронизируют содержимое в источники.",
"tools": "Показаны коннекторы, которые дают агентам инструменты.",
"showAll": "Показать все"
}
}
},
+16 -8
View File
@@ -1111,7 +1111,7 @@
"status": {
"connect": "連線",
"connected": "已連線",
"connectedCount": "已連線 {{count}} 個",
"connectedCount": "已連線 {{formatted}} 個",
"reconnect": "重新連線",
"needsAdminSetup": "需要管理員設定",
"disabledByAdmin": "已被管理員停用",
@@ -1181,12 +1181,14 @@
"always": "一律允許",
"ask": "需要核准",
"off": "關閉",
"label": "{{action}} 的權限"
"label": "{{action}} 的權限",
"saveFailed": "無法儲存權限。請再試一次。"
},
"disconnect": {
"title": "要中斷 {{name}} 的連線嗎?",
"body_zero": "在重新連線之前,此連線的工具將無法使用。",
"body_other": "你的 {{count}} 個來源會保留內容,但會停止同步。在重新連線之前,此連線的工具將無法使用。"
"body_other": "你的 {{formatted}} 個來源會保留內容,但會停止同步。在重新連線之前,此連線的工具將無法使用。",
"failed": "無法中斷連線。請再試一次。"
},
"fields": {
"token": "API 金鑰",
@@ -1216,9 +1218,9 @@
"doneTitle": "{{name}} 已連線",
"doneSummary": "已以 {{account}} 連線。{{sources}}同步中,{{tools}}已就緒。",
"doneSummaryNone": "已以 {{account}} 連線。",
"sourcesCount_other": "{{count}} 個來源",
"toolsCount_other": "{{count}} 個工具",
"toolsHeading": "工具({{count}})",
"sourcesCount_other": "{{formatted}} 個來源",
"toolsCount_other": "{{formatted}} 個工具",
"toolsHeading": "工具({{formatted}})",
"tryInChat": "在聊天中試試",
"done": "完成",
"connectFailed": "無法連線。請檢查資料後再試一次。",
@@ -1231,10 +1233,10 @@
"remove": {
"title": "要移除 {{name}} 嗎?",
"description": "儲存的登入資訊將被刪除。請選擇如何處理此連線提供的內容。",
"sourcesLabel_other": "它的 {{count}} 個來源",
"sourcesLabel_other": "它的 {{formatted}} 個來源",
"keepSources": "保留內容(停止同步)",
"deleteSources": "刪除",
"toolsLabel_other": "它的 {{count}} 個工具",
"toolsLabel_other": "它的 {{formatted}} 個工具",
"deleteTools": "刪除",
"keepTools": "保留並關閉",
"failed": "無法移除此連線。請再試一次。"
@@ -1257,6 +1259,12 @@
"memberNote": "每位成員須先連線自己的 {{name}} 帳號,才能使用此工具。",
"forced": "管理員已為此連接器的所有共用設定此項。",
"saveFailed": "無法儲存變更。請再試一次。"
},
"createFailed": "無法新增連接器。請再試一次。",
"capabilityFilter": {
"sync": "正在顯示將內容同步到來源的連接器。",
"tools": "正在顯示為代理提供工具的連接器。",
"showAll": "顯示全部"
}
}
},
+16 -8
View File
@@ -1111,7 +1111,7 @@
"status": {
"connect": "连接",
"connected": "已连接",
"connectedCount": "已连接 {{count}} 个",
"connectedCount": "已连接 {{formatted}} 个",
"reconnect": "重新连接",
"needsAdminSetup": "需要管理员设置",
"disabledByAdmin": "已被管理员停用",
@@ -1181,12 +1181,14 @@
"always": "始终允许",
"ask": "需要批准",
"off": "关闭",
"label": "{{action}} 的权限"
"label": "{{action}} 的权限",
"saveFailed": "无法保存权限。请重试。"
},
"disconnect": {
"title": "断开 {{name}}?",
"body_zero": "在重新连接之前,此连接的工具将无法使用。",
"body_other": "你的 {{count}} 个来源会保留内容,但将停止同步。在重新连接之前,此连接的工具将无法使用。"
"body_other": "你的 {{formatted}} 个来源会保留内容,但将停止同步。在重新连接之前,此连接的工具将无法使用。",
"failed": "无法断开连接。请重试。"
},
"fields": {
"token": "API 密钥",
@@ -1216,9 +1218,9 @@
"doneTitle": "{{name}} 已连接",
"doneSummary": "已作为 {{account}} 连接。{{sources}}正在同步,{{tools}}已就绪。",
"doneSummaryNone": "已作为 {{account}} 连接。",
"sourcesCount_other": "{{count}} 个来源",
"toolsCount_other": "{{count}} 个工具",
"toolsHeading": "工具({{count}})",
"sourcesCount_other": "{{formatted}} 个来源",
"toolsCount_other": "{{formatted}} 个工具",
"toolsHeading": "工具({{formatted}})",
"tryInChat": "在聊天中试试",
"done": "完成",
"connectFailed": "无法连接。请检查信息后重试。",
@@ -1231,10 +1233,10 @@
"remove": {
"title": "移除 {{name}}?",
"description": "保存的登录信息将被删除。请选择如何处理此连接提供的内容。",
"sourcesLabel_other": "它的 {{count}} 个来源",
"sourcesLabel_other": "它的 {{formatted}} 个来源",
"keepSources": "保留内容(停止同步)",
"deleteSources": "删除",
"toolsLabel_other": "它的 {{count}} 个工具",
"toolsLabel_other": "它的 {{formatted}} 个工具",
"deleteTools": "删除",
"keepTools": "保留并关闭",
"failed": "无法移除此连接。请重试。"
@@ -1257,6 +1259,12 @@
"memberNote": "每位成员需先连接自己的 {{name}} 账号才能使用此工具。",
"forced": "管理员已为此连接器的所有共享设定了此项。",
"saveFailed": "无法保存更改。请重试。"
},
"createFailed": "无法添加连接器。请重试。",
"capabilityFilter": {
"sync": "正在显示将内容同步到来源的连接器。",
"tools": "正在显示为智能体提供工具的连接器。",
"showAll": "显示全部"
}
}
},
+26 -23
View File
@@ -1,15 +1,15 @@
import { ArrowRight, Plus } from 'lucide-react';
import { ArrowRight } from 'lucide-react';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { Link } from 'react-router-dom';
import { Link, useNavigate } from 'react-router-dom';
import userService from '../api/services/userService';
import SkeletonLoader from '../components/SkeletonLoader';
import ToolIcon from '../components/ToolIcon';
import { Button } from '../components/ui/button';
import { Card, CardDescription, CardTitle } from '../components/ui/card';
import { Modal } from '../components/ui/modal';
import { Modal, ModalActions } from '../components/ui/modal';
import { SectionHeader } from '../components/ui/section-header';
import { useLoaderState } from '../hooks';
import {
@@ -40,6 +40,7 @@ export default function AddToolModal({
onDevicePaired?: (deviceId: string) => void;
}) {
const { t } = useTranslation();
const navigate = useNavigate();
const token = useSelector(selectToken);
const dispatch = useDispatch<AppDispatch>();
const catalog = useSelector(selectConnectorCatalog);
@@ -141,6 +142,28 @@ export default function AddToolModal({
onOpenChange={(o) => !o && setModalState('INACTIVE')}
title={t('settings.tools.selectToolSetup')}
size="xl"
footer={
<ModalActions
footerStart={
<Button variant="link" size="inline" asChild>
<Link
to="/settings/connectors"
onClick={() => setModalState('INACTIVE')}
>
{t('settings.tools.browseConnectors')}
<ArrowRight />
</Link>
</Button>
}
cancelLabel={t('cancel')}
onCancel={() => setModalState('INACTIVE')}
submitLabel={t('settings.connectors.addCustom')}
onSubmit={() => {
setModalState('INACTIVE');
navigate('/settings/connectors?filter=custom');
}}
/>
}
>
<div className="flex flex-col gap-6">
{loading ? (
@@ -205,26 +228,6 @@ export default function AddToolModal({
);
})
)}
<div className="flex flex-wrap items-center justify-between gap-3">
<Button variant="link" size="inline" asChild>
<Link
to="/settings/connectors"
onClick={() => setModalState('INACTIVE')}
>
{t('settings.tools.browseConnectors')}
<ArrowRight className="size-3" />
</Link>
</Button>
<Button variant="outline-primary" shape="pill" size="sm" asChild>
<Link
to="/settings/connectors?filter=custom"
onClick={() => setModalState('INACTIVE')}
>
<Plus />
{t('settings.connectors.addCustom')}
</Link>
</Button>
</div>
</div>
</Modal>
<ConfigToolModal
+27
View File
@@ -209,6 +209,33 @@ describe('Connectors page', () => {
expect(container.textContent).toContain('settings.connectors.empty');
});
it('says when the composer narrowed the list, and clears it', async () => {
await render('/settings/connectors?capability=sync');
expect(container.textContent).toContain(
'settings.connectors.capabilityFilter.sync',
);
expect(card('telegram')).toBeNull();
const showAll = Array.from(container.querySelectorAll('button')).find(
(button) =>
button.textContent === 'settings.connectors.capabilityFilter.showAll',
)!;
await act(async () => showAll.click());
expect(container.textContent).not.toContain(
'settings.connectors.capabilityFilter.sync',
);
expect(card('telegram')).not.toBeNull();
});
it('only offers categories that have connectors', async () => {
await render();
const pills = Array.from(
container.querySelectorAll('[data-slot="toggle-group-item"]'),
).map((item) => item.textContent);
expect(pills).toContain('settings.connectors.categories.all');
expect(pills).toContain('settings.connectors.categories.files');
expect(pills).not.toContain('settings.connectors.categories.database');
});
it('shows a retry when the catalog fails to load', async () => {
service.getCatalog.mockResolvedValue({ success: false });
await render();
+83 -20
View File
@@ -15,6 +15,13 @@ import {
DropdownMenuTrigger,
} from '../components/ui/dropdown-menu';
import { EmptyState } from '../components/ui/empty-state';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../components/ui/select';
import { ToggleGroup, ToggleGroupItem } from '../components/ui/toggle-group';
import ConnectionDrawer from '../connectors/ConnectionDrawer';
import ConnectorCard from '../connectors/ConnectorCard';
@@ -86,6 +93,21 @@ export default function Connectors() {
// "Connect more" in the composer opens the connectors that can do what the
// picker is for: sync content, or give tools.
const capability = searchParams.get('capability');
const clearCapability = () => {
searchParams.delete('capability');
setSearchParams(searchParams, { replace: true });
};
// Only categories that have something in them (connectors that need admin
// setup are not listed, which can empty one).
const filters = FILTERS.filter(
(key) =>
key === 'all' ||
key === filter ||
(key === 'connected'
? catalog.some(isConnected)
: catalog.some((connector) => connector.category === key)),
);
const visible = useMemo(() => {
const query = search.trim().toLowerCase();
@@ -163,19 +185,57 @@ export default function Connectors() {
</DropdownMenu>
}
>
<ToggleGroup
type="single"
value={filter}
onValueChange={(value) => value && setFilter(value as Filter)}
aria-label={t('settings.connectors.categoriesLabel')}
className="mb-6"
>
{FILTERS.map((key) => (
<ToggleGroupItem key={key} value={key}>
{t(`settings.connectors.categories.${key}`)}
</ToggleGroupItem>
))}
</ToggleGroup>
<div className="mb-6 flex flex-col gap-3">
{/* Nine pills wrap to four lines on a phone: a Select there. */}
<ToggleGroup
type="single"
value={filter}
onValueChange={(value) => value && setFilter(value as Filter)}
aria-label={t('settings.connectors.categoriesLabel')}
className="hidden sm:flex"
>
{filters.map((key) => (
<ToggleGroupItem key={key} value={key}>
{t(`settings.connectors.categories.${key}`)}
</ToggleGroupItem>
))}
</ToggleGroup>
<div className="sm:hidden">
<Select
value={filter}
onValueChange={(value) => setFilter(value as Filter)}
>
<SelectTrigger
size="field"
shape="pill"
className="w-full"
aria-label={t('settings.connectors.categoriesLabel')}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{filters.map((key) => (
<SelectItem key={key} value={key}>
{t(`settings.connectors.categories.${key}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{(capability === 'sync' || capability === 'tools') && (
<p className="text-muted-foreground text-sm">
{t(`settings.connectors.capabilityFilter.${capability}`)}{' '}
<Button
type="button"
variant="link"
size="inline"
onClick={clearCapability}
>
{t('settings.connectors.capabilityFilter.showAll')}
</Button>
</p>
)}
</div>
</PageToolbar>
{!loaded && !failed ? (
@@ -191,6 +251,7 @@ export default function Connectors() {
<Button
variant="outline"
size="sm"
shape="pill"
onClick={() => dispatch(loadConnectors({ token }))}
>
{t('retry')}
@@ -198,13 +259,15 @@ export default function Connectors() {
}
/>
) : visible.length === 0 ? (
<EmptyState
title={
filter === 'connected' && !search
? t('settings.connectors.empty')
: t('settings.connectors.noMatches')
}
/>
filter === 'connected' && !search ? (
<EmptyState title={t('settings.connectors.empty')} />
) : (
<EmptyState
size="xs"
illustration="none"
title={t('settings.connectors.noMatches')}
/>
)
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{visible.map((connector) => (
+19 -10
View File
@@ -27,6 +27,11 @@ import { Card, CardFooter, CardTitle } from '../components/ui/card';
import { ActionMenu, type MenuOption } from '../components/ui/dropdown-menu';
import { EmptyState } from '../components/ui/empty-state';
import { Pagination } from '../components/ui/pagination';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '../components/ui/tooltip';
import { useDebouncedValue, useLoaderState } from '../hooks';
import ConfirmationModal from '../modals/ConfirmationModal';
import { ActiveState, Doc, DocumentsProps } from '../models/misc';
@@ -662,15 +667,19 @@ export default function Sources({
</Badge>
)}
{connection && paused && (
<Badge
variant="warning"
title={t('settings.sources.paused', {
name: connection.name,
interpolation: { escapeValue: false },
})}
>
{t('settings.connectors.detail.paused')}
</Badge>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="warning" tabIndex={0}>
{t('settings.connectors.detail.paused')}
</Badge>
</TooltipTrigger>
<TooltipContent>
{t('settings.sources.paused', {
name: connection.name,
interpolation: { escapeValue: false },
})}
</TooltipContent>
</Tooltip>
)}
{document.ingestStatus === 'failed' && (
<Badge variant="destructive">
@@ -726,7 +735,7 @@ export default function Sources({
title={connection.account_label}
>
{t('settings.tools.viaConnection', {
name: connection.account_label,
name: connection.name,
interpolation: { escapeValue: false },
})}
</span>
+36 -33
View File
@@ -2,7 +2,7 @@ import { Pencil, RefreshCw, Trash2, Users } from 'lucide-react';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { useLocation, useNavigate } from 'react-router-dom';
import devicesService from '../api/services/devicesService';
import userService from '../api/services/userService';
@@ -12,7 +12,12 @@ import SkeletonLoader from '../components/SkeletonLoader';
import ToolIcon from '../components/ToolIcon';
import { Badge } from '../components/ui/badge';
import { Button } from '../components/ui/button';
import { Card, CardDescription, CardTitle } from '../components/ui/card';
import {
Card,
CardDescription,
CardFooter,
CardTitle,
} from '../components/ui/card';
import { Switch } from '../components/ui/switch';
import { ActionMenu, type MenuOption } from '../components/ui/dropdown-menu';
import { EmptyState } from '../components/ui/empty-state';
@@ -413,37 +418,6 @@ export default function Tools() {
>
{tool.customName || tool.displayName}
</CardTitle>
{(() => {
const connection = tool.connection_id
? connections.find(
(c) => c.id === tool.connection_id,
)
: undefined;
if (!connection) return null;
return (
<Button
variant="link"
size="inline"
asChild
className="mt-1 max-w-full justify-start"
>
<Link
to={`/settings/connectors?connector=${encodeURIComponent(connection.connector_key)}`}
>
<ConnectorIcon
icon={connection.icon}
className="size-3.5 shrink-0"
/>
<span className="truncate">
{t('settings.tools.viaConnection', {
name: connection.name,
interpolation: { escapeValue: false },
})}
</span>
</Link>
</Button>
);
})()}
<CardDescription
size="xs"
className="mt-1 line-clamp-4 max-h-24 overflow-hidden break-words"
@@ -453,6 +427,35 @@ export default function Tools() {
</CardDescription>
</div>
</div>
{(() => {
const connection = tool.connection_id
? connections.find(
(c) => c.id === tool.connection_id,
)
: undefined;
if (!connection) return null;
// The same meta line as a Sources tile; mr-12 keeps
// it clear of the status switch.
return (
<CardFooter>
<span className="mr-12 flex min-w-0 items-center gap-2">
<ConnectorIcon
icon={connection.icon}
className="size-3.5 shrink-0"
/>
<span
className="truncate"
title={connection.account_label}
>
{t('settings.tools.viaConnection', {
name: connection.name,
interpolation: { escapeValue: false },
})}
</span>
</span>
</CardFooter>
);
})()}
<div className="absolute right-4 bottom-4">
<Switch
checked={tool.status}
+8 -2
View File
@@ -1,4 +1,10 @@
import { CircleAlert, Trash2, UserRound, UsersRound } from 'lucide-react';
import {
CircleAlert,
Trash2,
TriangleAlert,
UserRound,
UsersRound,
} from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
@@ -580,7 +586,7 @@ export default function ShareToTeamModal({
)}
{credentialMode === 'owner' ? (
<Alert variant="warning" role="note">
<CircleAlert aria-hidden="true" />
<TriangleAlert aria-hidden="true" />
<AlertDescription>
{t('settings.connectors.share.ownerWarning', {
account: credentials.account,
+5 -22
View File
@@ -1,4 +1,4 @@
import { ChevronLeft, CircleAlert, FileText } from 'lucide-react';
import { ChevronLeft, FileText } from 'lucide-react';
import { envVar } from '@/env';
import { cn } from '@/lib/utils';
import { useCallback, useEffect, useRef, useState } from 'react';
@@ -28,8 +28,9 @@ import { Modal } from '../components/ui/modal';
import { Separator } from '../components/ui/separator';
import { OptionCard } from '../components/ui/option-card';
import { SectionHeader } from '../components/ui/section-header';
import { Alert, AlertDescription, AlertTitle } from '../components/ui/alert';
import ConnectorIcon from '../connectors/ConnectorIcon';
import ConnectorSetupNotice from '../connectors/ConnectorSetupNotice';
import { formatCount } from '../utils/dateTimeUtils';
import {
loadConnectors,
selectConnections,
@@ -1194,6 +1195,7 @@ function Upload({
if (accounts.length > 1)
return t('settings.connectors.status.connectedCount', {
count: accounts.length,
formatted: formatCount(accounts.length),
});
return t('settings.connectors.status.connect');
};
@@ -1274,26 +1276,7 @@ function Upload({
const renderSetupNotice = () =>
selectedConnector && needsSetup ? (
<Alert variant="warning">
<CircleAlert />
<AlertTitle>
{t('settings.connectors.status.needsAdminSetup')}
</AlertTitle>
<AlertDescription>
<div className="flex flex-col gap-2">
{selectedConnector.missing_settings.length > 0 ? (
<>
<span>{t('settings.connectors.setupSettings')}</span>
<code className="font-mono text-xs wrap-anywhere">
{selectedConnector.missing_settings.join(', ')}
</code>
</>
) : (
<span>{t('settings.connectors.askAdmin')}</span>
)}
</div>
</AlertDescription>
</Alert>
<ConnectorSetupNotice connector={selectedConnector} />
) : null;
return (
<Modal