Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions alembic/versions/0007_seed_connectors_manage_global.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"""seed connectors:manage:global permission and grant to admin

Revision ID: 0007_seed_connectors_manage_global
Revises: 0006_revoke_provider_override_nonadmin
Create Date: 2026-06-03 00:00:00.000000

Adds the admin-only ``connectors:manage:global`` permission (workspace-wide
enable/disable of connectors) and grants it to the built-in ``admin`` role.

Idempotent: skips the permission row if it already exists and skips the
``admin`` join row if it is already present. Mirrors the sync insert pattern
in ``0002_seed_roles_permissions``.

"""

import uuid
from collections.abc import Sequence

import sqlalchemy as sa

from alembic import op

revision: str = "0007_seed_connectors_manage_global"
down_revision: str | Sequence[str] | None = "0006_revoke_provider_override_nonadmin"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None

_PERM_NAME = "connectors:manage:global"
_PERM_RESOURCE = "connectors"
_PERM_ACTION = "manage:global"
_PERM_DESCRIPTION = "Enable/disable connectors workspace-wide"
_ROLE_NAME = "admin"


def upgrade() -> None:
bind = op.get_bind()

perm_id = bind.execute(
sa.text("SELECT id FROM permissions WHERE name = :name"),
{"name": _PERM_NAME},
).scalar()

if perm_id is None:
perm_id = str(uuid.uuid4())
perms_table = sa.table(
"permissions",
sa.column("id", sa.String),
sa.column("name", sa.String),
sa.column("resource", sa.String),
sa.column("action", sa.String),
sa.column("description", sa.String),
)
op.bulk_insert(
perms_table,
[
{
"id": perm_id,
"name": _PERM_NAME,
"resource": _PERM_RESOURCE,
"action": _PERM_ACTION,
"description": _PERM_DESCRIPTION,
}
],
)

role_id = bind.execute(
sa.text("SELECT id FROM roles WHERE name = :name"),
{"name": _ROLE_NAME},
).scalar()
if role_id is None:
return

already_granted = bind.execute(
sa.text("SELECT 1 FROM role_permissions WHERE role_id = :rid AND permission_id = :pid"),
{"rid": role_id, "pid": perm_id},
).scalar()
if already_granted is None:
rp_table = sa.table(
"role_permissions",
sa.column("role_id", sa.String),
sa.column("permission_id", sa.String),
)
op.bulk_insert(rp_table, [{"role_id": role_id, "permission_id": perm_id}])


def downgrade() -> None:
op.execute(
sa.text(
"DELETE FROM role_permissions WHERE permission_id IN "
"(SELECT id FROM permissions WHERE name = :name)"
).bindparams(sa.bindparam("name", _PERM_NAME))
)
op.execute(
sa.text("DELETE FROM permissions WHERE name = :name").bindparams(
sa.bindparam("name", _PERM_NAME)
)
)
67 changes: 67 additions & 0 deletions frontend/app/api/mutations/useToggleConnectorMutation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import type { Connector } from "../queries/useGetConnectorsQuery";

interface ToggleConnectorVariables {
connector: Connector;
enabled: boolean;
}

export const useToggleConnectorMutation = () => {
const queryClient = useQueryClient();

return useMutation({
mutationFn: async ({ connector, enabled }: ToggleConnectorVariables) => {
const response = await fetch(
`/api/connectors/${connector.type}/enabled`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled }),
},
);

if (!response.ok) {
const result = await response.json().catch(() => ({}));
throw new Error(
result?.detail?.error ||
result.error ||
`Failed to update ${connector.name}`,
);
}
return response.json();
},
onMutate: async ({ connector, enabled }) => {
await queryClient.cancelQueries({ queryKey: ["connectors"] });

const previousConnectors = queryClient.getQueryData<Connector[]>([
"connectors",
]);

if (previousConnectors) {
queryClient.setQueryData<Connector[]>(
["connectors"],
previousConnectors.map((c) =>
c.type === connector.type ? { ...c, enabled } : c,
),
);
}

return { previousConnectors };
},
onError: (err, { connector }, context) => {
if (context?.previousConnectors) {
queryClient.setQueryData(["connectors"], context.previousConnectors);
}
toast.error(`Failed to update ${connector.name}: ${err.message}`);
},
onSuccess: (_, { connector, enabled }) => {
toast.success(
`${connector.name} ${enabled ? "enabled" : "disabled"} for the workspace`,
);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["connectors"] });
},
});
};
4 changes: 4 additions & 0 deletions frontend/app/api/queries/useGetConnectorsQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ export interface Connector {
access_token?: string;
selectedFiles?: GoogleDriveFile[] | OneDriveFile[];
available?: boolean;
/** Admin-managed workspace toggle. Absent is treated as enabled. */
enabled?: boolean;
}
Comment on lines 33 to 37

interface Connection {
Expand Down Expand Up @@ -89,6 +91,7 @@ export const useGetConnectorsQuery = (
clientId: activeConnection.client_id,
baseUrl: activeConnection.base_url,
available: connectorData.available,
enabled: connectorData.enabled,
} as Connector;
}
}
Expand All @@ -102,6 +105,7 @@ export const useGetConnectorsQuery = (
type,
connectionId,
available: connectorData.available,
enabled: connectorData.enabled,
} as Connector;
}),
);
Expand Down
61 changes: 45 additions & 16 deletions frontend/app/settings/_components/connector-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Switch } from "@/components/ui/switch";
import { useIsCloudBrand } from "@/contexts/brand-context";
import { usePermissions } from "@/hooks/use-permissions";
import { cn } from "@/lib/utils";
Expand All @@ -23,6 +24,8 @@ export interface Connector {
available?: boolean;
status?: string;
connectionId?: string;
/** Admin-managed workspace toggle. Absent is treated as enabled. */
enabled?: boolean;
}

interface ConnectorCardProps {
Expand All @@ -34,6 +37,9 @@ interface ConnectorCardProps {
onNavigateToKnowledge: (connector: Connector) => void;
/** Optional: open a connector-specific settings/edit dialog */
onConfigure?: (connector: Connector) => void;
/** Admin-only: toggle the connector on/off workspace-wide */
onToggleEnabled?: (connector: Connector, enabled: boolean) => void;
isTogglingEnabled?: boolean;
}

export default function ConnectorCard({
Expand All @@ -44,6 +50,8 @@ export default function ConnectorCard({
onDisconnect,
onNavigateToKnowledge,
onConfigure,
onToggleEnabled,
isTogglingEnabled,
}: ConnectorCardProps) {
const isCloudBrand = useIsCloudBrand();
const { can, canAny } = usePermissions();
Expand All @@ -53,6 +61,8 @@ export default function ConnectorCard({
"connectors:delete:any",
]);
const canUpload = can("knowledge:upload");
const canManageGlobal = can("connectors:manage:global");
const isEnabled = connector.enabled !== false;
const isConnected =
connector.status === "connected" && connector.connectionId;

Expand All @@ -72,19 +82,36 @@ export default function ConnectorCard({
<CardIcon isActive={!!isConnected} activeBgColor="bg-white">
{connector.icon}
</CardIcon>
{isConnected ? (
<div
className={cn(
"flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium",
isCloudBrand
? "bg-primary/10 text-primary dark:bg-white/10 dark:text-layer-contextual-foreground"
: "bg-foreground text-muted",
)}
>
<span className="h-2 w-2 rounded-full bg-green-500" />
Active
</div>
) : null}
<div className="flex items-center gap-3">
{isConnected ? (
<div
className={cn(
"flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium",
isCloudBrand
? "bg-primary/10 text-primary dark:bg-white/10 dark:text-layer-contextual-foreground"
: "bg-foreground text-muted",
)}
>
<span className="h-2 w-2 rounded-full bg-green-500" />
Active
</div>
) : null}
{canManageGlobal && onToggleEnabled ? (
<Switch
checked={isEnabled}
disabled={isTogglingEnabled}
onCheckedChange={(checked) =>
onToggleEnabled(connector, checked)
}
aria-label={`${isEnabled ? "Disable" : "Enable"} ${connector.name} for the workspace`}
title={
isEnabled
? "Enabled for the workspace — toggle off to hide from all users"
: "Disabled for the workspace — toggle on to make available to all users"
}
/>
) : null}
</div>
</div>
<div>
<CardTitle
Expand All @@ -101,9 +128,11 @@ export default function ConnectorCard({
isCloudBrand && "!text-layer-contextual-foreground",
)}
>
{isConnected || connector?.available
? `${connector.name} is configured.`
: "Not configured."}
{canManageGlobal && !isEnabled
? `${connector.name} is disabled for the workspace.`
: isConnected || connector?.available
? `${connector.name} is configured.`
: "Not configured."}
</CardDescription>
</div>
</div>
Expand Down
15 changes: 15 additions & 0 deletions frontend/app/settings/_components/connector-cards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useRouter } from "next/navigation";
import { useCallback, useState } from "react";
import { useConnectConnectorMutation } from "@/app/api/mutations/useConnectConnectorMutation";
import { useDisconnectConnectorMutation } from "@/app/api/mutations/useDisconnectConnectorMutation";
import { useToggleConnectorMutation } from "@/app/api/mutations/useToggleConnectorMutation";
import {
type Connector as QueryConnector,
useGetConnectorsQuery,
Expand Down Expand Up @@ -34,6 +35,7 @@ export default function ConnectorCards() {

const connectMutation = useConnectConnectorMutation();
const disconnectMutation = useDisconnectConnectorMutation();
const toggleEnabledMutation = useToggleConnectorMutation();

const getConnectorIcon = useCallback((iconName: string) => {
const iconMap: { [key: string]: React.ReactElement } = {
Expand Down Expand Up @@ -74,6 +76,13 @@ export default function ConnectorCards() {
disconnectMutation.mutate(connector as unknown as QueryConnector);
};

const handleToggleEnabled = (connector: Connector, enabled: boolean) => {
toggleEnabledMutation.mutate({
connector: connector as unknown as QueryConnector,
enabled,
});
};

const navigateToKnowledgePage = (connector: Connector) => {
const provider = connector.type.replace(/-/g, "_");
router.push(`/upload/${provider}`);
Expand Down Expand Up @@ -120,6 +129,12 @@ export default function ConnectorCards() {
onDisconnect={handleDisconnect}
onNavigateToKnowledge={navigateToKnowledgePage}
onConfigure={getConfigureHandler(connector)}
onToggleEnabled={handleToggleEnabled}
isTogglingEnabled={
toggleEnabledMutation.isPending &&
toggleEnabledMutation.variables?.connector?.type ===
connector.type
}
/>
))
)}
Expand Down
Loading
Loading