mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-10-23 11:53:08 -05:00
feat(connectors): data-access group picker for perm-synced connectors (#15033)
This commit is contained in:
1 parent
c8c8c04ce6
commit
d29ce87d41
24 files changed
+447
-17
No files matched your search
@@ -814,6 +814,15 @@ def associate_credential_to_connector(
|
||||
if metadata.access_type == AccessType.SYNC_RESTRICTED:
|
||||
# Becomes creatable in the same change that enforces its data-access
|
||||
# groups at query time, so no restricted pair exists without them.
|
||||
# TODO(evan, ENG-4342): remove this rejection in the enforcement change,
|
||||
# together with:
|
||||
# - the allowed-connector query filter and the /chat/file check
|
||||
# - the creation path: restriction_group_ids, validation, persistence
|
||||
# (branch jtahara/connector-group-restrictions-creation-path)
|
||||
# - SYNC-only checks in connector_credential_pair.py: listing
|
||||
# visibility, tier/source validation, get_all_auto_sync_cc_pairs,
|
||||
# get_cc_pairs_by_source
|
||||
# - creating the pair and its data-access rows in one transaction
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.FEATURE_NOT_AVAILABLE,
|
||||
"Restricted perm-synced connectors are not available yet.",
|
||||
|
||||
@@ -132,6 +132,7 @@ from onyx.server.documents.models import (
|
||||
ConnectorCredentialPairIdentifier,
|
||||
ConnectorFileInfo,
|
||||
ConnectorFilesResponse,
|
||||
ConnectorGroupRestrictionsStatus,
|
||||
ConnectorIndexingStatusLite,
|
||||
ConnectorIndexingStatusLiteResponse,
|
||||
ConnectorRequestSubmission,
|
||||
@@ -226,6 +227,19 @@ def upsert_gmail_service_account_credential(
|
||||
return ObjectCreationIdResponse(id=credential.id)
|
||||
|
||||
|
||||
@router.get("/connector-group-restrictions")
|
||||
def get_connector_group_restrictions_status(
|
||||
_: User = Depends(
|
||||
require_permission(Permission.MANAGE_CONNECTORS, allow_scope=True)
|
||||
),
|
||||
) -> ConnectorGroupRestrictionsStatus:
|
||||
"""Whether connector forms offer the data-access group restriction. Scoped
|
||||
managers read it here because the security settings API is admin-only."""
|
||||
return ConnectorGroupRestrictionsStatus(
|
||||
enabled=get_security_settings().allow_connector_group_restrictions
|
||||
)
|
||||
|
||||
|
||||
@router.get("/admin/connector/google-drive/check-auth/{credential_id}")
|
||||
def check_drive_tokens(
|
||||
credential_id: int,
|
||||
|
||||
@@ -823,6 +823,10 @@ class AuthStatus(BaseModel):
|
||||
authenticated: bool
|
||||
|
||||
|
||||
class ConnectorGroupRestrictionsStatus(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class AuthUrl(BaseModel):
|
||||
auth_url: str
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { CheckboxField } from "@/refresh-components/form/LabeledCheckboxField";
|
||||
import { isPermSynced } from "@/lib/connectors/accessType";
|
||||
|
||||
export interface SlackChannelConfigFormFieldsProps {
|
||||
isUpdate: boolean;
|
||||
@@ -67,8 +68,8 @@ export function SlackChannelConfigFormFields({
|
||||
|
||||
// Helper function to check if a document set contains sync connectors
|
||||
const documentSetContainsSync = (documentSet: DocumentSetSummary) => {
|
||||
return documentSet.cc_pair_summaries.some(
|
||||
(summary) => summary.access_type === "sync"
|
||||
return documentSet.cc_pair_summaries.some((summary) =>
|
||||
isPermSynced(summary.access_type)
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
import { useField } from "formik";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { AutoSyncOptions } from "./AutoSyncOptions";
|
||||
import { ConnectorGroupRestrictionPicker } from "@/sections/connectors/ConnectorGroupRestrictionPicker";
|
||||
import { useConnectorGroupRestrictionsEnabled } from "@/lib/connectors/hooks";
|
||||
import { useTierAtLeast } from "@/hooks/useTierAtLeast";
|
||||
import { Tier } from "@/lib/settings/types";
|
||||
import { useEffect, useMemo } from "react";
|
||||
@@ -42,6 +44,7 @@ export function AccessTypeForm({
|
||||
// both are Business+ features.
|
||||
const businessTier = useTierAtLeast(Tier.BUSINESS);
|
||||
const showAutoSync = businessTier && isValidAutoSyncSource(connector);
|
||||
const groupRestrictionsEnabled = useConnectorGroupRestrictionsEnabled();
|
||||
|
||||
const selectedAuthMethod = currentCredential?.credential_json?.[
|
||||
"authentication_method"
|
||||
@@ -151,6 +154,9 @@ export function AccessTypeForm({
|
||||
}
|
||||
includeDefault={false}
|
||||
/>
|
||||
{access_type.value === "sync" &&
|
||||
showAutoSync &&
|
||||
groupRestrictionsEnabled && <ConnectorGroupRestrictionPicker />}
|
||||
{access_type.value === "sync" && showAutoSync && (
|
||||
<AutoSyncOptions connectorType={connector as ValidAutoSyncSource} />
|
||||
)}
|
||||
|
||||
@@ -2152,12 +2152,29 @@
|
||||
"title": "خطأ في مزامنة عضويات المجموعات"
|
||||
}
|
||||
},
|
||||
"groupRestriction": {
|
||||
"empty": {
|
||||
"description": "يحتفظ الجميع بالوصول المتزامن من المصدر.",
|
||||
"title": "لا توجد قيود حتى تتم إضافة مجموعات"
|
||||
},
|
||||
"memberCount": "{count, plural, zero {لا أعضاء} one {عضو واحد} two {عضوان} few {# أعضاء} many {# عضوًا} other {# عضو}}",
|
||||
"picker": {
|
||||
"placeholder": "أضف مجموعات لتقييد الوصول إلى هذا الموصل"
|
||||
},
|
||||
"remove": {
|
||||
"tooltip": "إزالة {name}"
|
||||
},
|
||||
"toggle": {
|
||||
"description": "قصر الوصول على أعضاء المجموعات المحددة. تظل الأذونات المتزامنة سارية.",
|
||||
"title": "تقييد الوصول بالمجموعات"
|
||||
}
|
||||
},
|
||||
"groupSelector": {
|
||||
"assignGroupAccess": {
|
||||
"title": "إسناد وصول المجموعات لهذا الموصّل"
|
||||
},
|
||||
"assignToGroup": {
|
||||
"title": "إسناد هذا الموصّل إلى مجموعة"
|
||||
"title": "المجموعات التي يمكنها إدارة هذا الموصل"
|
||||
},
|
||||
"scopedManagerGroups": {
|
||||
"description": "حدد مجموعة أو أكثر من المجموعات التي تديرها لمنح حق الوصول إلى هذا الموصّل"
|
||||
|
||||
@@ -2152,12 +2152,29 @@
|
||||
"title": "Fehler bei der Synchronisierung der Gruppenmitgliedschaften"
|
||||
}
|
||||
},
|
||||
"groupRestriction": {
|
||||
"empty": {
|
||||
"description": "Alle behalten den aus der Quelle synchronisierten Zugriff.",
|
||||
"title": "Keine Beschränkung, bis Gruppen hinzugefügt werden"
|
||||
},
|
||||
"memberCount": "{count, plural, one {# Mitglied} other {# Mitglieder}}",
|
||||
"picker": {
|
||||
"placeholder": "Gruppen hinzufügen, um den Zugriff auf diesen Connector zu beschränken"
|
||||
},
|
||||
"remove": {
|
||||
"tooltip": "{name} entfernen"
|
||||
},
|
||||
"toggle": {
|
||||
"description": "Zugriff auf Mitglieder der ausgewählten Gruppen beschränken. Synchronisierte Berechtigungen gelten weiterhin.",
|
||||
"title": "Zugriff auf Gruppen beschränken"
|
||||
}
|
||||
},
|
||||
"groupSelector": {
|
||||
"assignGroupAccess": {
|
||||
"title": "Gruppenzugriff für diesen Konnektor zuweisen"
|
||||
},
|
||||
"assignToGroup": {
|
||||
"title": "Diesen Konnektor einer Gruppe zuweisen"
|
||||
"title": "Gruppen, die diesen Connector verwalten können"
|
||||
},
|
||||
"scopedManagerGroups": {
|
||||
"description": "Wähle eine oder mehrere der von dir verwalteten Gruppen aus, um Zugriff auf diesen Konnektor zu gewähren"
|
||||
|
||||
@@ -2152,12 +2152,29 @@
|
||||
"title": "Group Membership Sync Error"
|
||||
}
|
||||
},
|
||||
"groupRestriction": {
|
||||
"empty": {
|
||||
"description": "Everyone keeps the access synced from the source.",
|
||||
"title": "No restriction until groups are added"
|
||||
},
|
||||
"memberCount": "{count, plural, one {# member} other {# members}}",
|
||||
"picker": {
|
||||
"placeholder": "Add groups to restrict access to this connector"
|
||||
},
|
||||
"remove": {
|
||||
"tooltip": "Remove {name}"
|
||||
},
|
||||
"toggle": {
|
||||
"description": "Limit access to members of selected groups. Synced permissions still apply.",
|
||||
"title": "Restrict Access to Groups"
|
||||
}
|
||||
},
|
||||
"groupSelector": {
|
||||
"assignGroupAccess": {
|
||||
"title": "Assign group access for this Connector"
|
||||
},
|
||||
"assignToGroup": {
|
||||
"title": "Assign this Connector to a group"
|
||||
"title": "Groups that can manage this Connector"
|
||||
},
|
||||
"scopedManagerGroups": {
|
||||
"description": "Select one or more of the groups you manage to give access to this Connector"
|
||||
|
||||
@@ -2152,12 +2152,29 @@
|
||||
"title": "Error de sincronización de pertenencia a grupos"
|
||||
}
|
||||
},
|
||||
"groupRestriction": {
|
||||
"empty": {
|
||||
"description": "Todos conservan el acceso sincronizado desde el origen.",
|
||||
"title": "Sin restricción hasta que se añadan grupos"
|
||||
},
|
||||
"memberCount": "{count, plural, one {# miembro} other {# miembros}}",
|
||||
"picker": {
|
||||
"placeholder": "Añade grupos para restringir el acceso a este conector"
|
||||
},
|
||||
"remove": {
|
||||
"tooltip": "Quitar {name}"
|
||||
},
|
||||
"toggle": {
|
||||
"description": "Limita el acceso a los miembros de los grupos seleccionados. Los permisos sincronizados siguen aplicándose.",
|
||||
"title": "Restringir acceso a grupos"
|
||||
}
|
||||
},
|
||||
"groupSelector": {
|
||||
"assignGroupAccess": {
|
||||
"title": "Asignar acceso de grupos a este conector"
|
||||
},
|
||||
"assignToGroup": {
|
||||
"title": "Asignar este conector a un grupo"
|
||||
"title": "Grupos que pueden administrar este conector"
|
||||
},
|
||||
"scopedManagerGroups": {
|
||||
"description": "Selecciona uno o más de los grupos que administras para dar acceso a este conector"
|
||||
|
||||
@@ -2152,12 +2152,29 @@
|
||||
"title": "Erreur de synchronisation de l'appartenance aux groupes"
|
||||
}
|
||||
},
|
||||
"groupRestriction": {
|
||||
"empty": {
|
||||
"description": "Tout le monde conserve l'accès synchronisé depuis la source.",
|
||||
"title": "Aucune restriction tant qu'aucun groupe n'est ajouté"
|
||||
},
|
||||
"memberCount": "{count, plural, one {# membre} other {# membres}}",
|
||||
"picker": {
|
||||
"placeholder": "Ajouter des groupes pour restreindre l'accès à ce connecteur"
|
||||
},
|
||||
"remove": {
|
||||
"tooltip": "Retirer {name}"
|
||||
},
|
||||
"toggle": {
|
||||
"description": "Limite l'accès aux membres des groupes sélectionnés. Les permissions synchronisées s'appliquent toujours.",
|
||||
"title": "Restreindre l'accès à des groupes"
|
||||
}
|
||||
},
|
||||
"groupSelector": {
|
||||
"assignGroupAccess": {
|
||||
"title": "Attribuer l'accès des groupes à ce connecteur"
|
||||
},
|
||||
"assignToGroup": {
|
||||
"title": "Attribuer ce connecteur à un groupe"
|
||||
"title": "Groupes pouvant gérer ce connecteur"
|
||||
},
|
||||
"scopedManagerGroups": {
|
||||
"description": "Sélectionnez un ou plusieurs des groupes que vous gérez pour donner accès à ce connecteur"
|
||||
|
||||
@@ -2152,12 +2152,29 @@
|
||||
"title": "グループメンバーシップの同期エラー"
|
||||
}
|
||||
},
|
||||
"groupRestriction": {
|
||||
"empty": {
|
||||
"description": "全員がソースから同期されたアクセス権を保持します。",
|
||||
"title": "グループを追加するまで制限はありません"
|
||||
},
|
||||
"memberCount": "{count, plural, other {#人のメンバー}}",
|
||||
"picker": {
|
||||
"placeholder": "このコネクタへのアクセスを制限するグループを追加"
|
||||
},
|
||||
"remove": {
|
||||
"tooltip": "{name}を削除"
|
||||
},
|
||||
"toggle": {
|
||||
"description": "選択したグループのメンバーにアクセスを限定します。同期された権限も引き続き適用されます。",
|
||||
"title": "グループへのアクセス制限"
|
||||
}
|
||||
},
|
||||
"groupSelector": {
|
||||
"assignGroupAccess": {
|
||||
"title": "このコネクタへのグループアクセスを割り当てる"
|
||||
},
|
||||
"assignToGroup": {
|
||||
"title": "このコネクタをグループに割り当てる"
|
||||
"title": "このコネクタを管理できるグループ"
|
||||
},
|
||||
"scopedManagerGroups": {
|
||||
"description": "このコネクタへのアクセスを付与するには、管理しているグループを1つ以上選択してください"
|
||||
|
||||
@@ -2152,12 +2152,29 @@
|
||||
"title": "그룹 멤버십 동기화 오류"
|
||||
}
|
||||
},
|
||||
"groupRestriction": {
|
||||
"empty": {
|
||||
"description": "모든 사용자가 소스에서 동기화된 접근 권한을 유지합니다.",
|
||||
"title": "그룹을 추가하기 전까지 제한 없음"
|
||||
},
|
||||
"memberCount": "{count, plural, other {멤버 #명}}",
|
||||
"picker": {
|
||||
"placeholder": "이 커넥터에 대한 접근을 제한할 그룹 추가"
|
||||
},
|
||||
"remove": {
|
||||
"tooltip": "{name} 제거"
|
||||
},
|
||||
"toggle": {
|
||||
"description": "선택한 그룹의 멤버로 접근을 제한합니다. 동기화된 권한은 계속 적용됩니다.",
|
||||
"title": "그룹으로 접근 제한"
|
||||
}
|
||||
},
|
||||
"groupSelector": {
|
||||
"assignGroupAccess": {
|
||||
"title": "이 커넥터에 대한 그룹 액세스 할당"
|
||||
},
|
||||
"assignToGroup": {
|
||||
"title": "이 커넥터를 그룹에 할당"
|
||||
"title": "이 커넥터를 관리할 수 있는 그룹"
|
||||
},
|
||||
"scopedManagerGroups": {
|
||||
"description": "이 커넥터에 대한 액세스 권한을 부여하려면 관리 중인 그룹을 하나 이상 선택하세요"
|
||||
|
||||
@@ -2152,12 +2152,29 @@
|
||||
"title": "Erro na sincronização das associações de grupo"
|
||||
}
|
||||
},
|
||||
"groupRestriction": {
|
||||
"empty": {
|
||||
"description": "Todos mantêm o acesso sincronizado da origem.",
|
||||
"title": "Sem restrição até que grupos sejam adicionados"
|
||||
},
|
||||
"memberCount": "{count, plural, one {# membro} other {# membros}}",
|
||||
"picker": {
|
||||
"placeholder": "Adicione grupos para restringir o acesso a este conector"
|
||||
},
|
||||
"remove": {
|
||||
"tooltip": "Remover {name}"
|
||||
},
|
||||
"toggle": {
|
||||
"description": "Limite o acesso aos membros dos grupos selecionados. As permissões sincronizadas continuam valendo.",
|
||||
"title": "Restringir acesso a grupos"
|
||||
}
|
||||
},
|
||||
"groupSelector": {
|
||||
"assignGroupAccess": {
|
||||
"title": "Atribuir acesso de grupos a este conector"
|
||||
},
|
||||
"assignToGroup": {
|
||||
"title": "Atribuir este conector a um grupo"
|
||||
"title": "Grupos que podem gerenciar este conector"
|
||||
},
|
||||
"scopedManagerGroups": {
|
||||
"description": "Selecione um ou mais dos grupos que você gerencia para dar acesso a este conector"
|
||||
|
||||
@@ -2152,12 +2152,29 @@
|
||||
"title": "用户组成员同步错误"
|
||||
}
|
||||
},
|
||||
"groupRestriction": {
|
||||
"empty": {
|
||||
"description": "所有人保留从源系统同步的访问权限。",
|
||||
"title": "添加群组前不设限制"
|
||||
},
|
||||
"memberCount": "{count, plural, other {# 名成员}}",
|
||||
"picker": {
|
||||
"placeholder": "添加群组以限制对此连接器的访问"
|
||||
},
|
||||
"remove": {
|
||||
"tooltip": "移除 {name}"
|
||||
},
|
||||
"toggle": {
|
||||
"description": "仅允许所选群组的成员访问。同步的权限仍然适用。",
|
||||
"title": "限制为群组访问"
|
||||
}
|
||||
},
|
||||
"groupSelector": {
|
||||
"assignGroupAccess": {
|
||||
"title": "为此连接器分配组访问权限"
|
||||
},
|
||||
"assignToGroup": {
|
||||
"title": "将此连接器分配给一个组"
|
||||
"title": "可以管理此连接器的群组"
|
||||
},
|
||||
"scopedManagerGroups": {
|
||||
"description": "选择一个或多个您管理的组以授予对此连接器的访问权限"
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { isPermSynced, toWireAccess } from "@/lib/connectors/accessType";
|
||||
|
||||
describe("toWireAccess", () => {
|
||||
it("restricts a synced connector when groups are chosen", () => {
|
||||
expect(
|
||||
toWireAccess("sync", {
|
||||
restrict_access_to_groups: true,
|
||||
restriction_group_ids: [3, 7],
|
||||
})
|
||||
).toEqual({
|
||||
access_type: "sync_restricted",
|
||||
restriction_group_ids: [3, 7],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps plain sync when the switch is on but no groups are chosen", () => {
|
||||
expect(
|
||||
toWireAccess("sync", {
|
||||
restrict_access_to_groups: true,
|
||||
restriction_group_ids: [],
|
||||
})
|
||||
).toEqual({ access_type: "sync", restriction_group_ids: [] });
|
||||
});
|
||||
|
||||
it("drops stale groups when the switch is off", () => {
|
||||
expect(
|
||||
toWireAccess("sync", {
|
||||
restrict_access_to_groups: false,
|
||||
restriction_group_ids: [3],
|
||||
})
|
||||
).toEqual({ access_type: "sync", restriction_group_ids: [] });
|
||||
});
|
||||
|
||||
it("never restricts non-synced access types", () => {
|
||||
for (const accessType of ["public", "private"] as const) {
|
||||
expect(
|
||||
toWireAccess(accessType, {
|
||||
restrict_access_to_groups: true,
|
||||
restriction_group_ids: [3],
|
||||
})
|
||||
).toEqual({ access_type: accessType, restriction_group_ids: [] });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPermSynced", () => {
|
||||
it("treats both synced types as synced", () => {
|
||||
expect(isPermSynced("sync")).toBe(true);
|
||||
expect(isPermSynced("sync_restricted")).toBe(true);
|
||||
expect(isPermSynced("private")).toBe(false);
|
||||
expect(isPermSynced("public")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { AccessType } from "@/lib/types";
|
||||
|
||||
// Perm sync plus a data-access group restriction (ENG-4342). The wire name is
|
||||
// provisional until the connector editing initiative confirms it.
|
||||
export const SYNC_RESTRICTED_ACCESS_TYPE =
|
||||
"sync_restricted" satisfies AccessType;
|
||||
|
||||
export function isPermSynced(accessType: AccessType): boolean {
|
||||
return accessType === "sync" || accessType === SYNC_RESTRICTED_ACCESS_TYPE;
|
||||
}
|
||||
|
||||
export interface ConnectorGroupRestrictionFormValues {
|
||||
restrict_access_to_groups: boolean;
|
||||
restriction_group_ids: number[];
|
||||
}
|
||||
|
||||
export interface WireAccess {
|
||||
access_type: AccessType;
|
||||
restriction_group_ids: number[];
|
||||
}
|
||||
|
||||
// The form keeps "sync" plus a restriction flag, so the dropdown never sees the
|
||||
// fourth value. A switched-on restriction with no groups restricts nobody.
|
||||
export function toWireAccess(
|
||||
accessType: AccessType,
|
||||
restriction: ConnectorGroupRestrictionFormValues
|
||||
): WireAccess {
|
||||
const restricted =
|
||||
accessType === "sync" &&
|
||||
restriction.restrict_access_to_groups &&
|
||||
restriction.restriction_group_ids.length > 0;
|
||||
return restricted
|
||||
? {
|
||||
access_type: SYNC_RESTRICTED_ACCESS_TYPE,
|
||||
restriction_group_ids: restriction.restriction_group_ids,
|
||||
}
|
||||
: { access_type: accessType, restriction_group_ids: [] };
|
||||
}
|
||||
@@ -245,3 +245,20 @@ export function useFederatedConnector(
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
interface ConnectorGroupRestrictionsStatus {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether connector forms offer the data-access group restriction. Set by the
|
||||
* workspace toggle in Security and Hardening. Fails closed: hidden until the
|
||||
* setting loads, and hidden if the request fails.
|
||||
*/
|
||||
export function useConnectorGroupRestrictionsEnabled(): boolean {
|
||||
const { data } = useSWR<ConnectorGroupRestrictionsStatus>(
|
||||
SWR_KEYS.connectorGroupRestrictions,
|
||||
errorHandlingFetcher
|
||||
);
|
||||
return data?.enabled ?? false;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as Yup from "yup";
|
||||
import type { AccessTypeGroupSelectorFormType } from "@/components/admin/connectors/AccessTypeGroupSelector";
|
||||
import type { ConnectorGroupRestrictionFormValues } from "@/lib/connectors/accessType";
|
||||
import type {
|
||||
ConfigurableSources,
|
||||
IndexAttemptStage,
|
||||
@@ -60,13 +61,17 @@ const buildInitialValuesForFields = (
|
||||
|
||||
export function createConnectorInitialValues(
|
||||
connector: ConfigurableSources
|
||||
): Record<string, any> & AccessTypeGroupSelectorFormType {
|
||||
): Record<string, any> &
|
||||
AccessTypeGroupSelectorFormType &
|
||||
ConnectorGroupRestrictionFormValues {
|
||||
const configuration = connectorConfigs[connector];
|
||||
|
||||
return {
|
||||
name: "",
|
||||
groups: [],
|
||||
access_type: "public",
|
||||
restrict_access_to_groups: false,
|
||||
restriction_group_ids: [],
|
||||
...buildInitialValuesForFields(configuration.values),
|
||||
...buildInitialValuesForFields(configuration.advanced_values),
|
||||
};
|
||||
|
||||
@@ -71,6 +71,7 @@ export const SWR_KEYS = {
|
||||
indexingStatus: "/api/manage/admin/connector/indexing-status",
|
||||
adminConnectorStatus: "/api/manage/admin/connector/status",
|
||||
federatedConnectors: "/api/federated",
|
||||
connectorGroupRestrictions: "/api/manage/connector-group-restrictions",
|
||||
|
||||
// ── Google Connectors ─────────────────────────────────────────────────────
|
||||
googleConnectorCredentials: (service: "gmail" | "google-drive") =>
|
||||
|
||||
@@ -184,7 +184,7 @@ export type ValidStatuses =
|
||||
| "not_started";
|
||||
export type TaskStatus = "PENDING" | "STARTED" | "SUCCESS" | "FAILURE";
|
||||
export type Feedback = "like" | "dislike" | "mixed";
|
||||
export type AccessType = "public" | "private" | "sync";
|
||||
export type AccessType = "public" | "private" | "sync" | "sync_restricted";
|
||||
export type ProcessingMode = "REGULAR";
|
||||
export type SessionType = "Chat" | "Search" | "Slack";
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { useField } from "formik";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
EmptyMessageCard,
|
||||
InputSingleComboBox,
|
||||
InputSwitch,
|
||||
} from "@opal/components";
|
||||
import { ContentAction, InputHorizontal } from "@opal/layouts";
|
||||
import { SvgUsers, SvgX } from "@opal/icons";
|
||||
import { useUserGroups } from "@/lib/hooks";
|
||||
import type { ConnectorGroupRestrictionFormValues } from "@/lib/connectors/accessType";
|
||||
|
||||
type FieldName = keyof ConnectorGroupRestrictionFormValues;
|
||||
|
||||
const RESTRICT_FIELD: FieldName = "restrict_access_to_groups";
|
||||
const GROUP_IDS_FIELD: FieldName = "restriction_group_ids";
|
||||
|
||||
/**
|
||||
* Data-access restriction for a perm-synced connector: only members of the
|
||||
* chosen groups can read its documents, on top of the source's permissions.
|
||||
* Separate from the management-access groups, which decide who can configure
|
||||
* the connector.
|
||||
*/
|
||||
export function ConnectorGroupRestrictionPicker() {
|
||||
const t = useTranslations("admin.connector.groupRestriction");
|
||||
const { data: userGroups } = useUserGroups();
|
||||
const [restrict, , restrictHelpers] = useField<boolean>(RESTRICT_FIELD);
|
||||
const [groupIds, , groupIdsHelpers] = useField<number[]>(GROUP_IDS_FIELD);
|
||||
|
||||
const selectedIds = new Set(groupIds.value);
|
||||
const selectedGroups = (userGroups ?? []).filter((group) =>
|
||||
selectedIds.has(group.id)
|
||||
);
|
||||
const options = (userGroups ?? [])
|
||||
.filter((group) => !selectedIds.has(group.id))
|
||||
.map((group) => ({
|
||||
value: String(group.id),
|
||||
title: group.name,
|
||||
description: t("memberCount", { count: group.users.length }),
|
||||
}));
|
||||
|
||||
function addGroup(value: string) {
|
||||
const id = Number(value);
|
||||
if (!Number.isInteger(id) || selectedIds.has(id)) return;
|
||||
void groupIdsHelpers.setValue([...groupIds.value, id]);
|
||||
}
|
||||
|
||||
function removeGroup(id: number) {
|
||||
void groupIdsHelpers.setValue(
|
||||
groupIds.value.filter((groupId) => groupId !== id)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<InputHorizontal
|
||||
title={t("toggle.title")}
|
||||
description={t("toggle.description")}
|
||||
withLabel
|
||||
>
|
||||
<InputSwitch
|
||||
checked={restrict.value}
|
||||
onCheckedChange={(checked) => void restrictHelpers.setValue(checked)}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
|
||||
{restrict.value && (
|
||||
<>
|
||||
<InputSingleComboBox
|
||||
value=""
|
||||
onValueChange={addGroup}
|
||||
options={options}
|
||||
placeholder={t("picker.placeholder")}
|
||||
/>
|
||||
|
||||
{selectedGroups.length === 0 ? (
|
||||
<EmptyMessageCard
|
||||
sizePreset="main-ui"
|
||||
icon={SvgUsers}
|
||||
title={t("empty.title")}
|
||||
description={t("empty.description")}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid max-h-80 grid-cols-1 gap-2 overflow-y-auto sm:grid-cols-2">
|
||||
{selectedGroups.map((group) => (
|
||||
<Card key={group.id} color="background-tint-01" padding={2}>
|
||||
<ContentAction
|
||||
icon={SvgUsers}
|
||||
title={group.name}
|
||||
description={t("memberCount", {
|
||||
count: group.users.length,
|
||||
})}
|
||||
sizePreset="main-content"
|
||||
variant="section"
|
||||
rightChildren={
|
||||
<Button
|
||||
icon={SvgX}
|
||||
prominence="tertiary"
|
||||
tooltip={t("remove.tooltip", { name: group.name })}
|
||||
onClick={() => removeGroup(group.id)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -66,6 +66,7 @@ import { deleteConnector } from "@/lib/connector";
|
||||
import ConnectorDocsLink from "@/components/admin/connectors/ConnectorDocsLink";
|
||||
import { SvgArrowExchange, SvgKey, SvgSimpleLoader } from "@opal/icons";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toWireAccess } from "@/lib/connectors/accessType";
|
||||
|
||||
export interface AdvancedConfig {
|
||||
refreshFreq: number;
|
||||
@@ -334,7 +335,9 @@ export default function AddConnector({
|
||||
const {
|
||||
name,
|
||||
groups,
|
||||
access_type,
|
||||
access_type: formAccessType,
|
||||
restrict_access_to_groups,
|
||||
restriction_group_ids,
|
||||
pruneFreq,
|
||||
indexingStart,
|
||||
refreshFreq,
|
||||
@@ -342,6 +345,12 @@ export default function AddConnector({
|
||||
...connector_specific_config
|
||||
} = values;
|
||||
|
||||
const wireAccess = toWireAccess(formAccessType, {
|
||||
restrict_access_to_groups,
|
||||
restriction_group_ids,
|
||||
});
|
||||
const access_type = wireAccess.access_type;
|
||||
|
||||
// Apply special transforms according to application logic
|
||||
const transformedConnectorSpecificConfig = Object.entries(
|
||||
connector_specific_config
|
||||
@@ -473,6 +482,8 @@ export default function AddConnector({
|
||||
currentCredential ||
|
||||
liveGDriveCredential ||
|
||||
liveGmailCredential;
|
||||
// TODO(evan, ENG-4342): send wireAccess.restriction_group_ids
|
||||
// once the backend accepts them; this call creates the cc-pair.
|
||||
const linkCredentialResponse = await linkCredential(
|
||||
response.id,
|
||||
credential!.id,
|
||||
|
||||
@@ -69,6 +69,7 @@ import { useUser } from "@/providers/UserProvider";
|
||||
import { resolveAllErrorsForCCPair } from "@/lib/targeted_reindex";
|
||||
import { SWR_KEYS } from "@/lib/swr-keys";
|
||||
import { can } from "@/lib/permissions/resource-actions";
|
||||
import { isPermSynced } from "@/lib/connectors/accessType";
|
||||
// synchronize these validations with the SQLAlchemy connector class until we have a
|
||||
// centralized schema for both frontend and backend
|
||||
const RefreshFrequencySchema = Yup.object().shape({
|
||||
@@ -666,7 +667,7 @@ function Main({ ccPairId }: { ccPairId: number }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ccPair.access_type === "sync" && (
|
||||
{isPermSynced(ccPair.access_type) && (
|
||||
<>
|
||||
<div className="w-[200px]">
|
||||
{/* TODO: Remove className and switch to text03 once Text is fully integrated across this page */}
|
||||
@@ -783,7 +784,7 @@ function Main({ ccPairId }: { ccPairId: number }) {
|
||||
)}
|
||||
|
||||
{indexAttempts &&
|
||||
(ccPair.access_type === "sync" ? (
|
||||
(isPermSynced(ccPair.access_type) ? (
|
||||
<Section height="auto" alignItems="stretch" className="mt-6">
|
||||
<SyncAttemptsTabs
|
||||
ccPair={ccPair}
|
||||
|
||||
@@ -38,6 +38,7 @@ import { ConnectorStaggeredSkeleton } from "./ConnectorRowSkeleton";
|
||||
import { Button } from "@opal/components";
|
||||
import { SvgSettings } from "@opal/icons";
|
||||
import { can } from "@/lib/permissions/resource-actions";
|
||||
import { isPermSynced } from "@/lib/connectors/accessType";
|
||||
|
||||
// Helper to handle navigation with cmd/ctrl+click support
|
||||
// NOTE: using this rather than Next/Link (or similar) since shadcn
|
||||
@@ -202,7 +203,7 @@ function ConnectorRow({
|
||||
<Badge variant={isEditable ? "success" : "default"} icon={FiUnlock}>
|
||||
{t("status.access.organizationPublic.label")}
|
||||
</Badge>
|
||||
) : ccPairsIndexingStatus.access_type === "sync" ? (
|
||||
) : isPermSynced(ccPairsIndexingStatus.access_type) ? (
|
||||
<Badge
|
||||
variant={isEditable ? "auto-sync" : "default"}
|
||||
icon={FiRefreshCw}
|
||||
|
||||
Reference in new issue
Block a user