feat(connectors): account sharing UI on the add-connector page (#15930)

This commit is contained in:
Raunak Bhagat authored and GitHub committed 2026-10-13 02:30:03 +00:00
1 parent 19e5041c2f
commit 794a5e4832
28 files changed
+722 -213

No files matched your search

@@ -73,7 +73,7 @@ meanings in one prop. Use `tooltip` for hover text.
| `rounding` | `Rounding` | `3` | Corner radius step (`N / 4` rem, or `"full"`); height is content-driven |
| `width` | `WidthVariant` | `"full"` | Container width |
| `padding` | `0 \| 0.5 \| 1 \| 2` | `0.5` | Padding around the inner `ContentAction`, as a spacing step (`N / 4` rem) |
| `tooltip` | `string` | — | Tooltip text shown on hover |
| `tooltip` | `string \| RichStr` | — | Tooltip shown on hover; markdown, e.g. a link |
| `tooltipSide` | `TooltipSide` | `"top"` | Tooltip side |
### Content (pass-through to ContentAction)
@@ -86,6 +86,7 @@ meanings in one prop. Use `tooltip` for hover text.
| `description` | `string` | — | Description below the title |
| `descriptionMaxLines` | `number` | — | Cap the description at N lines and truncate the rest. Unset wraps without a limit — a row showing user-authored text usually wants `1` |
| `suffix` | `string` | — | Muted text beside the title, like "(Default)". Shown only by the `heading` variant with a `main-*` `sizePreset`; the default `headline` row drops it |
| `tag` | `TagProps` | — | Tag beside the title, like a plan badge; `heading` variant only |
| `sizePreset` | `SizePreset` | `"headline"` | Content size preset |
| `variant` | `ContentVariant` | `"heading"` | Content layout variant |
| `rightChildren` | `ReactNode` | — | Content after the label (e.g. action button) |
@@ -8,6 +8,7 @@ import type {
Rounding,
} from "@opal/types";
import { Tooltip, type TooltipSide } from "@opal/components";
import type { TagProps } from "@opal/components/tag/components";
import {
type ContentActionProps,
type ContentVariant,
@@ -62,6 +63,12 @@ type RowContentProps = {
*/
suffix?: string;
/**
* Tag beside the title, like a plan badge. Shown only by the `heading`
* variant with a `main-*` size preset.
*/
tag?: TagProps;
/**
* Cap the description at N lines and truncate the rest. Unset wraps without
* a limit, so a row showing user-authored text usually wants `1`.
@@ -137,8 +144,8 @@ type LineItemButtonOwnProps = Pick<
/** Container width. @default "full" */
width?: ExtremaSizeVariants;
/** Tooltip text shown on hover. */
tooltip?: string;
/** Tooltip shown on hover. Accepts markdown, e.g. a link. */
tooltip?: string | RichStr;
/** Which side the tooltip appears on. @default "top" */
tooltipSide?: TooltipSide;
@@ -184,6 +191,7 @@ function LineItemButton({
description,
descriptionMaxLines,
suffix,
tag,
rightChildren,
sizePreset,
variant,
@@ -269,6 +277,7 @@ function LineItemButton({
description,
descriptionMaxLines,
suffix,
tag,
rightChildren,
sizePreset,
variant,
@@ -138,7 +138,7 @@ The element the list positions against and matches in width, when that is not th
| Kind | Fields | Enter |
| -------- | ----------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| `option` | `value`, `title`, `description?`, `descriptionMaxLines?`, `suffix?`, `icon?` | `onSelect(option)`; closes (single) |
| `option` | `value`, `title`, `description?`, `descriptionMaxLines?`, `suffix?`, `icon?`, `tag?` (a badge beside the title), `tooltip?` (why a disabled row is off; markdown; shown to the row's right) | `onSelect(option)`; closes (single) |
| `action` | `id`, `title`, `description?`, `icon?`, `danger?`, `tooltip?`, `keepOpen?`, and `onSelect(views)` and/or `href` (+ `target?`) | runs `onSelect`; an `href` row is a real link and navigates; closes unless `keepOpen` or the handler moved the stack |
| `toggle` | `id`, `title`, `description?`, `icon?`, `checked`, `onCheckedChange` | flips `checked`; stays open |
| `custom` | `id`, `render(row)`, `onActivate?(views)`, `onSecondary?(views)`, `keepOpen?` | `onActivate`; closes unless `keepOpen` or the handler moved the stack |
@@ -124,16 +124,20 @@ export const Row = React.memo(function Row({
title: row.title,
description: row.description,
suffix: row.kind === "option" ? row.suffix : undefined,
tag: row.kind === "option" ? row.tag : undefined,
descriptionMaxLines:
row.kind === "option" ? row.descriptionMaxLines : undefined,
color: row.kind === "action" && row.danger ? "danger" : undefined,
tooltip: row.kind === "action" ? row.tooltip : undefined,
tooltip:
row.kind === "action" || row.kind === "option" ? row.tooltip : undefined,
// Beside the list, so it never covers the row above.
tooltipSide: "end",
sizePreset,
padding: sizePreset === "secondary" ? 0 : 0.5,
// `body` resolves to `ContentSm`, which has no description or suffix
// slot; a row with either takes the `heading` layout.
// `body` resolves to `ContentSm`, which has no description, suffix or
// tag slot; a row with any of them takes the `heading` layout.
variant:
row.description || (row.kind === "option" && row.suffix)
row.description || (row.kind === "option" && (row.suffix || row.tag))
? "heading"
: "body",
id,
@@ -1,4 +1,5 @@
import type { IconFunctionComponent, RichStr } from "@opal/types";
import type { TagProps } from "@opal/components/tag/components";
import type { InputTypeInProps } from "@opal/components/inputs/texts/input-type-in/components";
// ---------------------------------------------------------------------------
@@ -25,6 +26,10 @@ export interface DropdownOption extends DropdownRowBase {
icon?: IconFunctionComponent;
/** Lines the description may take before it clamps, for text a user wrote. */
descriptionMaxLines?: number;
/** Tag beside the title, like a plan badge. */
tag?: TagProps;
/** Hover text on the row, for the reason it is disabled. Accepts markdown. */
tooltip?: string | RichStr;
}
/**
@@ -40,8 +45,8 @@ export type DropdownAction = DropdownRowBase & {
icon?: IconFunctionComponent;
/** A destructive command: the row reads in the danger colour. */
danger?: boolean;
/** Hover text on the row, for the reason it is disabled. */
tooltip?: string;
/** Hover text on the row, for the reason it is disabled. Accepts markdown. */
tooltip?: string | RichStr;
keepOpen?: boolean;
} & (
| {
@@ -1,5 +1,6 @@
import type { RichStr } from "@opal/types";
import type { IconFunctionComponent } from "@opal/types";
import type { TagProps } from "@opal/components/tag/components";
import type { InputTypeInTagProps } from "@opal/components/inputs/texts/input-type-in-tag/components";
/**
@@ -16,6 +17,10 @@ export type SelectOption = {
suffix?: string;
icon?: IconFunctionComponent;
disabled?: boolean;
/** Tag beside the title, like a plan badge. */
tag?: TagProps;
/** Hover text on the row, for the reason it is disabled. Accepts markdown. */
tooltip?: string | RichStr;
};
/**
@@ -28,7 +28,7 @@ import type { PreviewHighlightTarget } from "./Preview";
import { SvgEdit } from "@opal/icons";
import { useTierAtLeast } from "@/hooks/useTierAtLeast";
import { Tier } from "@/lib/settings/types";
import { planTagProps } from "@/lib/tier-badge";
import { PlanTag } from "@/lib/app/plans";
interface AppearanceThemeSettingsProps {
selectedLogo: File | null;
@@ -530,9 +530,7 @@ export const AppearanceThemeSettings = forwardRef<
>
<FormField.Label>
{t("helpLink.label")}
{!enterpriseTier && (
<Tag {...planTagProps("enterprise")} size="sm" />
)}
{!enterpriseTier && <PlanTag plan="enterprise" size="sm" />}
</FormField.Label>
<FormField.Control asChild>
<InputTypeIn
+29 -9
View File
@@ -1857,9 +1857,6 @@
"restoreTooltip": "استعادة الافتراضي"
},
"description": "حدد مدة احتفاظ {appName} بالمحادثات في مؤسستك.",
"enterprisePlanTag": {
"label": "خطة Enterprise"
},
"forever": {
"label": "إلى الأبد"
},
@@ -1879,9 +1876,6 @@
"title": "الاحتفاظ بسجل المحادثات"
},
"searchMode": {
"businessPlanTag": {
"label": "خطة Business"
},
"description": "وضع واجهة للبحث السريع في المستندات عبر مؤسستك.",
"noConnectorsTooltip": "قم بإعداد موصلات لاستخدام وضع البحث",
"tierTooltip": "يتطلب وضع البحث خطة Business أو Enterprise.",
@@ -2772,7 +2766,22 @@
},
"rerunFailed": {
"toast": "تعذّر بدء الفحوصات. حاول مرة أخرى."
},
"shareSaved": {
"toast": "تم تحديث المشاركة."
},
"shareFailed": {
"toast": "تعذّر تغيير من يمكنه استخدام هذا الحساب. حاول مرة أخرى."
},
"onlyMeOwnerOnly": {
"tooltip": "يمكن فقط لمن أضاف هذا الحساب أن يجعله خاصًا."
},
"shareLocked": {
"tooltip": "يمكن فقط لمن أضاف هذا الحساب، أو لمسؤول تمت مشاركته معه، تغيير من يمكنه استخدامه."
}
},
"accountUnavailable": {
"toast": "لم يعد هذا الحساب متاحًا لك. اختر حسابًا آخر."
}
},
"credentialRequired": {
@@ -9004,9 +9013,6 @@
"description": "عند إيقافه، ترفض جميع نقاط نهاية البوابة الطلبات، وتتوقف الرموز الموجودة عن العمل، ويُخفى نطاق البوابة من منتقي الرموز. تتم السيطرة على حركة بيئة Craft المعزولة بشكل منفصل عبر إعدادات Craft."
}
},
"businessPlanTag": {
"label": "خطة Business"
},
"tierTooltip": "تتطلب بوابة LLM خطة Business أو Enterprise.",
"scopeNote": {
"title": "يقوم كل مستخدم بتكوين رمزه الخاص وتفاصيل الاتصال ضمن الإعدادات → بوابة LLM."
@@ -12094,6 +12100,20 @@
"empty": "هذه الورقة فارغة.",
"tooLarge": "هذه الورقة كبيرة جدًا بحيث يتعذر معاينتها. نزّل الملف لعرضها."
}
},
"plans": {
"free": {
"title": "الخطة المجانية",
"tooltip": "متاح في جميع الخطط."
},
"business": {
"title": "خطة الأعمال",
"tooltip": "يتطلب [خطة الأعمال أو المؤسسات](/admin/billing)."
},
"enterprise": {
"title": "خطة المؤسسات",
"tooltip": "يتطلب [خطة المؤسسات](/admin/billing)."
}
}
},
"craft": {
+29 -9
View File
@@ -1857,9 +1857,6 @@
"restoreTooltip": "Standard wiederherstellen"
},
"description": "Legen Sie fest, wie lange {appName} Chats in Ihrer Organisation aufbewahren soll.",
"enterprisePlanTag": {
"label": "Enterprise-Tarif"
},
"forever": {
"label": "Unbegrenzt"
},
@@ -1879,9 +1876,6 @@
"title": "Chat-Verlauf aufbewahren"
},
"searchMode": {
"businessPlanTag": {
"label": "Business-Tarif"
},
"description": "Oberflächenmodus für die schnelle Dokumentensuche in Ihrer Organisation.",
"noConnectorsTooltip": "Richten Sie Konnektoren ein, um den Suchmodus zu verwenden",
"tierTooltip": "Der Suchmodus erfordert den Business- oder Enterprise-Tarif.",
@@ -2772,7 +2766,22 @@
},
"rerunFailed": {
"toast": "Die Prüfungen konnten nicht gestartet werden. Versuchen Sie es erneut."
},
"shareSaved": {
"toast": "Freigabe aktualisiert."
},
"shareFailed": {
"toast": "Die Freigabe dieses Kontos konnte nicht geändert werden. Versuchen Sie es erneut."
},
"onlyMeOwnerOnly": {
"tooltip": "Nur die Person, die dieses Konto hinzugefügt hat, kann es privat machen."
},
"shareLocked": {
"tooltip": "Nur die Person, die dieses Konto hinzugefügt hat, oder ein Admin, mit dem es geteilt ist, kann ändern, wer es nutzen darf."
}
},
"accountUnavailable": {
"toast": "Dieses Konto ist für Sie nicht mehr verfügbar. Wählen Sie ein anderes Konto."
}
},
"credentialRequired": {
@@ -9004,9 +9013,6 @@
"description": "Wenn deaktiviert, lehnen alle Gateway-Endpunkte Anfragen ab, vorhandene Token funktionieren nicht mehr, und der Gateway-Bereich wird in der Token-Auswahl ausgeblendet. Der Craft-Sandbox-Datenverkehr wird separat über die Craft-Einstellungen gesteuert."
}
},
"businessPlanTag": {
"label": "Business-Tarif"
},
"tierTooltip": "Das LLM Gateway erfordert den Business- oder Enterprise-Tarif.",
"scopeNote": {
"title": "Jeder Benutzer konfiguriert sein eigenes Token und die Verbindungsdetails unter Einstellungen → LLM Gateway."
@@ -12094,6 +12100,20 @@
"empty": "Dieses Blatt ist leer.",
"tooLarge": "Dieses Blatt ist zu groß für die Vorschau. Laden Sie die Datei herunter, um es anzusehen."
}
},
"plans": {
"free": {
"title": "Free-Tarif",
"tooltip": "In allen Tarifen verfügbar."
},
"business": {
"title": "Business-Tarif",
"tooltip": "Erfordert den [Business- oder Enterprise-Tarif](/admin/billing)."
},
"enterprise": {
"title": "Enterprise-Tarif",
"tooltip": "Erfordert den [Enterprise-Tarif](/admin/billing)."
}
}
},
"craft": {
+29 -9
View File
@@ -1857,9 +1857,6 @@
"restoreTooltip": "Restore Default"
},
"description": "Specify how long {appName} should retain chats in your organization.",
"enterprisePlanTag": {
"label": "Enterprise Plan"
},
"forever": {
"label": "Forever"
},
@@ -1879,9 +1876,6 @@
"title": "Keep Chat History"
},
"searchMode": {
"businessPlanTag": {
"label": "Business Plan"
},
"description": "UI mode for quick document search across your organization.",
"noConnectorsTooltip": "Set up connectors to use Search Mode",
"tierTooltip": "Search Mode requires the Business or Enterprise plan.",
@@ -2772,7 +2766,22 @@
},
"rerunFailed": {
"toast": "Couldn't start the checks. Try again."
},
"shareSaved": {
"toast": "Sharing updated."
},
"shareFailed": {
"toast": "Couldn't change who can use this account. Try again."
},
"onlyMeOwnerOnly": {
"tooltip": "Only the person who added this account can make it private."
},
"shareLocked": {
"tooltip": "Only the person who added this account, or an admin it is shared with, can change who may use it."
}
},
"accountUnavailable": {
"toast": "This account is no longer available to you. Pick another account."
}
},
"credentialRequired": {
@@ -9004,9 +9013,6 @@
"description": "When off, every gateway endpoint rejects requests, existing tokens stop working, and the gateway scope is hidden from the token picker. Craft sandbox traffic is controlled separately by the Craft settings."
}
},
"businessPlanTag": {
"label": "Business Plan"
},
"tierTooltip": "The LLM Gateway requires the Business or Enterprise plan.",
"scopeNote": {
"title": "Each user configures their own token and connection details under Settings → LLM Gateway."
@@ -12094,6 +12100,20 @@
"empty": "This sheet is empty.",
"tooLarge": "This sheet is too large to preview — download the file to view it."
}
},
"plans": {
"free": {
"title": "Free Plan",
"tooltip": "Available on every plan."
},
"business": {
"title": "Business Plan",
"tooltip": "Requires the [Business or Enterprise plan](/admin/billing)."
},
"enterprise": {
"title": "Enterprise Plan",
"tooltip": "Requires the [Enterprise plan](/admin/billing)."
}
}
},
"craft": {
+29 -9
View File
@@ -1857,9 +1857,6 @@
"restoreTooltip": "Restaurar el valor predeterminado"
},
"description": "Indica cuánto tiempo debe conservar {appName} los chats de tu organización.",
"enterprisePlanTag": {
"label": "Plan Enterprise"
},
"forever": {
"label": "Para siempre"
},
@@ -1879,9 +1876,6 @@
"title": "Conservar el historial de chats"
},
"searchMode": {
"businessPlanTag": {
"label": "Plan Business"
},
"description": "Modo de interfaz para buscar documentos rápidamente en tu organización.",
"noConnectorsTooltip": "Configura conectores para usar el modo de búsqueda",
"tierTooltip": "El modo de búsqueda requiere el plan Business o Enterprise.",
@@ -2772,7 +2766,22 @@
},
"rerunFailed": {
"toast": "No se pudieron iniciar las comprobaciones. Inténtalo de nuevo."
},
"shareSaved": {
"toast": "Uso compartido actualizado."
},
"shareFailed": {
"toast": "No se pudo cambiar quién puede usar esta cuenta. Inténtalo de nuevo."
},
"onlyMeOwnerOnly": {
"tooltip": "Solo quien añadió esta cuenta puede hacerla privada."
},
"shareLocked": {
"tooltip": "Solo quien añadió esta cuenta, o un administrador con quien está compartida, puede cambiar quién puede usarla."
}
},
"accountUnavailable": {
"toast": "Esta cuenta ya no está disponible para ti. Elige otra cuenta."
}
},
"credentialRequired": {
@@ -9004,9 +9013,6 @@
"description": "Cuando está desactivado, todos los endpoints de la puerta de enlace rechazan las solicitudes, los tokens existentes dejan de funcionar y el ámbito de la puerta de enlace se oculta del selector de tokens. El tráfico del sandbox de Craft se controla por separado en la configuración de Craft."
}
},
"businessPlanTag": {
"label": "Plan Business"
},
"tierTooltip": "LLM Gateway requiere el plan Business o Enterprise.",
"scopeNote": {
"title": "Cada usuario configura su propio token y los datos de conexión en Configuración → LLM Gateway."
@@ -12094,6 +12100,20 @@
"empty": "Esta hoja está vacía.",
"tooLarge": "Esta hoja es demasiado grande para la vista previa. Descarga el archivo para verla."
}
},
"plans": {
"free": {
"title": "Plan gratuito",
"tooltip": "Disponible en todos los planes."
},
"business": {
"title": "Plan Business",
"tooltip": "Requiere el [plan Business o Enterprise](/admin/billing)."
},
"enterprise": {
"title": "Plan Enterprise",
"tooltip": "Requiere el [plan Enterprise](/admin/billing)."
}
}
},
"craft": {
+29 -9
View File
@@ -1857,9 +1857,6 @@
"restoreTooltip": "Rétablir la valeur par défaut"
},
"description": "Indiquez combien de temps {appName} doit conserver les discussions de votre organisation.",
"enterprisePlanTag": {
"label": "Forfait Enterprise"
},
"forever": {
"label": "Toujours"
},
@@ -1879,9 +1876,6 @@
"title": "Conserver l'historique des discussions"
},
"searchMode": {
"businessPlanTag": {
"label": "Forfait Business"
},
"description": "Mode d'interface pour la recherche rapide de documents dans toute votre organisation.",
"noConnectorsTooltip": "Configurez des connecteurs pour utiliser le mode Recherche",
"tierTooltip": "Le mode Recherche nécessite le forfait Business ou Enterprise.",
@@ -2772,7 +2766,22 @@
},
"rerunFailed": {
"toast": "Impossible de lancer les vérifications. Réessayez."
},
"shareSaved": {
"toast": "Partage mis à jour."
},
"shareFailed": {
"toast": "Impossible de modifier qui peut utiliser ce compte. Réessayez."
},
"onlyMeOwnerOnly": {
"tooltip": "Seule la personne qui a ajouté ce compte peut le rendre privé."
},
"shareLocked": {
"tooltip": "Seule la personne qui a ajouté ce compte, ou un administrateur avec qui il est partagé, peut modifier qui peut l'utiliser."
}
},
"accountUnavailable": {
"toast": "Ce compte n'est plus disponible pour vous. Choisissez un autre compte."
}
},
"credentialRequired": {
@@ -9004,9 +9013,6 @@
"description": "Lorsque cette option est désactivée, tous les points de terminaison de la passerelle rejettent les requêtes, les jetons existants cessent de fonctionner et la portée de la passerelle est masquée dans le sélecteur de jetons. Le trafic du sandbox Craft est contrôlé séparément par les paramètres Craft."
}
},
"businessPlanTag": {
"label": "Forfait Business"
},
"tierTooltip": "LLM Gateway nécessite le forfait Business ou Enterprise.",
"scopeNote": {
"title": "Chaque utilisateur configure son propre jeton et les détails de connexion sous Paramètres → LLM Gateway."
@@ -12094,6 +12100,20 @@
"empty": "Cette feuille est vide.",
"tooLarge": "Cette feuille est trop volumineuse pour l'aperçu. Téléchargez le fichier pour la consulter."
}
},
"plans": {
"free": {
"title": "Offre gratuite",
"tooltip": "Disponible dans toutes les offres."
},
"business": {
"title": "Offre Business",
"tooltip": "Nécessite l'[offre Business ou Enterprise](/admin/billing)."
},
"enterprise": {
"title": "Offre Enterprise",
"tooltip": "Nécessite l'[offre Enterprise](/admin/billing)."
}
}
},
"craft": {
+29 -9
View File
@@ -1857,9 +1857,6 @@
"restoreTooltip": "既定値に戻す"
},
"description": "組織のチャットを {appName} が保持する期間を指定します。",
"enterprisePlanTag": {
"label": "Enterprise プラン"
},
"forever": {
"label": "無期限"
},
@@ -1879,9 +1876,6 @@
"title": "チャット履歴の保持"
},
"searchMode": {
"businessPlanTag": {
"label": "Business プラン"
},
"description": "組織全体のドキュメントをすばやく検索するための UI モードです。",
"noConnectorsTooltip": "検索モードを使用するにはコネクターを設定してください",
"tierTooltip": "検索モードには Business プランまたは Enterprise プランが必要です。",
@@ -2772,7 +2766,22 @@
},
"rerunFailed": {
"toast": "チェックを開始できませんでした。もう一度お試しください。"
},
"shareSaved": {
"toast": "共有を更新しました。"
},
"shareFailed": {
"toast": "このアカウントを使用できるユーザーを変更できませんでした。もう一度お試しください。"
},
"onlyMeOwnerOnly": {
"tooltip": "このアカウントを追加したユーザーのみが非公開にできます。"
},
"shareLocked": {
"tooltip": "このアカウントを追加したユーザー、または共有されている管理者のみが、使用できるユーザーを変更できます。"
}
},
"accountUnavailable": {
"toast": "このアカウントは利用できなくなりました。別のアカウントを選択してください。"
}
},
"credentialRequired": {
@@ -9004,9 +9013,6 @@
"description": "オフにすると、すべてのゲートウェイエンドポイントがリクエストを拒否し、既存のトークンは動作しなくなり、トークン選択からゲートウェイスコープが非表示になります。Craft サンドボックスのトラフィックは Craft 設定で個別に制御されます。"
}
},
"businessPlanTag": {
"label": "Business プラン"
},
"tierTooltip": "LLM ゲートウェイには Business プランまたは Enterprise プランが必要です。",
"scopeNote": {
"title": "各ユーザーは設定 → LLM ゲートウェイで独自のトークンと接続情報を設定します。"
@@ -12094,6 +12100,20 @@
"empty": "このシートは空です。",
"tooLarge": "このシートは大きすぎてプレビューできません。ファイルをダウンロードして表示してください。"
}
},
"plans": {
"free": {
"title": "無料プラン",
"tooltip": "すべてのプランで利用できます。"
},
"business": {
"title": "ビジネスプラン",
"tooltip": "[ビジネスプランまたはエンタープライズプラン](/admin/billing)が必要です。"
},
"enterprise": {
"title": "エンタープライズプラン",
"tooltip": "[エンタープライズプラン](/admin/billing)が必要です。"
}
}
},
"craft": {
+29 -9
View File
@@ -1857,9 +1857,6 @@
"restoreTooltip": "기본값 복원"
},
"description": "{appName}가 조직의 채팅을 보관할 기간을 지정하세요.",
"enterprisePlanTag": {
"label": "Enterprise 플랜"
},
"forever": {
"label": "무기한"
},
@@ -1879,9 +1876,6 @@
"title": "채팅 기록 보관"
},
"searchMode": {
"businessPlanTag": {
"label": "Business 플랜"
},
"description": "조직 전체 문서를 빠르게 검색하는 UI 모드입니다.",
"noConnectorsTooltip": "검색 모드를 사용하려면 커넥터를 설정하세요",
"tierTooltip": "검색 모드는 Business 또는 Enterprise 플랜이 필요합니다.",
@@ -2772,7 +2766,22 @@
},
"rerunFailed": {
"toast": "검사를 시작할 수 없습니다. 다시 시도하세요."
},
"shareSaved": {
"toast": "공유가 업데이트되었습니다."
},
"shareFailed": {
"toast": "이 계정을 사용할 수 있는 사용자를 변경하지 못했습니다. 다시 시도하세요."
},
"onlyMeOwnerOnly": {
"tooltip": "이 계정을 추가한 사람만 비공개로 설정할 수 있습니다."
},
"shareLocked": {
"tooltip": "이 계정을 추가한 사람 또는 공유된 관리자만 사용할 수 있는 사람을 변경할 수 있습니다."
}
},
"accountUnavailable": {
"toast": "이 계정을 더 이상 사용할 수 없습니다. 다른 계정을 선택하세요."
}
},
"credentialRequired": {
@@ -9004,9 +9013,6 @@
"description": "끄면 모든 게이트웨이 엔드포인트가 요청을 거부하고, 기존 토큰이 작동을 멈추며, 토큰 선택기에서 게이트웨이 범위가 숨겨집니다. Craft 샌드박스 트래픽은 Craft 설정에서 별도로 제어됩니다."
}
},
"businessPlanTag": {
"label": "Business 플랜"
},
"tierTooltip": "LLM 게이트웨이는 Business 또는 Enterprise 플랜이 필요합니다.",
"scopeNote": {
"title": "각 사용자는 설정 → LLM 게이트웨이에서 자체 토큰과 연결 정보를 구성합니다."
@@ -12094,6 +12100,20 @@
"empty": "이 시트는 비어 있습니다.",
"tooLarge": "이 시트는 너무 커서 미리 볼 수 없습니다. 파일을 다운로드하여 확인하세요."
}
},
"plans": {
"free": {
"title": "무료 플랜",
"tooltip": "모든 플랜에서 사용할 수 있습니다."
},
"business": {
"title": "비즈니스 플랜",
"tooltip": "[비즈니스 또는 엔터프라이즈 플랜](/admin/billing)이 필요합니다."
},
"enterprise": {
"title": "엔터프라이즈 플랜",
"tooltip": "[엔터프라이즈 플랜](/admin/billing)이 필요합니다."
}
}
},
"craft": {
+29 -9
View File
@@ -1857,9 +1857,6 @@
"restoreTooltip": "Restaurar padrão"
},
"description": "Especifique por quanto tempo o {appName} deve reter os chats na sua organização.",
"enterprisePlanTag": {
"label": "Plano Enterprise"
},
"forever": {
"label": "Para sempre"
},
@@ -1879,9 +1876,6 @@
"title": "Manter o histórico de chats"
},
"searchMode": {
"businessPlanTag": {
"label": "Plano Business"
},
"description": "Modo de interface para pesquisa rápida de documentos em toda a sua organização.",
"noConnectorsTooltip": "Configure conectores para usar o modo de pesquisa",
"tierTooltip": "O modo de pesquisa requer o plano Business ou Enterprise.",
@@ -2772,7 +2766,22 @@
},
"rerunFailed": {
"toast": "Não foi possível iniciar as verificações. Tente novamente."
},
"shareSaved": {
"toast": "Compartilhamento atualizado."
},
"shareFailed": {
"toast": "Não foi possível alterar quem pode usar esta conta. Tente novamente."
},
"onlyMeOwnerOnly": {
"tooltip": "Somente quem adicionou esta conta pode torná-la privada."
},
"shareLocked": {
"tooltip": "Somente quem adicionou esta conta, ou um administrador com quem ela foi compartilhada, pode alterar quem pode usá-la."
}
},
"accountUnavailable": {
"toast": "Esta conta não está mais disponível para você. Escolha outra conta."
}
},
"credentialRequired": {
@@ -9004,9 +9013,6 @@
"description": "Quando desativado, todos os endpoints do gateway rejeitam solicitações, os tokens existentes param de funcionar e o escopo do gateway fica oculto no seletor de tokens. O tráfego do sandbox do Craft é controlado separadamente nas configurações do Craft."
}
},
"businessPlanTag": {
"label": "Plano Business"
},
"tierTooltip": "O LLM Gateway requer o plano Business ou Enterprise.",
"scopeNote": {
"title": "Cada usuário configura seu próprio token e os dados de conexão em Configurações → LLM Gateway."
@@ -12094,6 +12100,20 @@
"empty": "Esta folha está vazia.",
"tooLarge": "Esta folha é grande demais para a pré-visualização. Baixe o arquivo para visualizá-la."
}
},
"plans": {
"free": {
"title": "Plano gratuito",
"tooltip": "Disponível em todos os planos."
},
"business": {
"title": "Plano Business",
"tooltip": "Requer o [plano Business ou Enterprise](/admin/billing)."
},
"enterprise": {
"title": "Plano Enterprise",
"tooltip": "Requer o [plano Enterprise](/admin/billing)."
}
}
},
"craft": {
+29 -9
View File
@@ -1857,9 +1857,6 @@
"restoreTooltip": "恢复默认"
},
"description": "指定 {appName} 在您的组织中保留对话的时长。",
"enterprisePlanTag": {
"label": "企业版套餐"
},
"forever": {
"label": "永久"
},
@@ -1879,9 +1876,6 @@
"title": "保留对话历史"
},
"searchMode": {
"businessPlanTag": {
"label": "商业版套餐"
},
"description": "用于在组织内快速搜索文档的界面模式。",
"noConnectorsTooltip": "设置连接器后才能使用搜索模式",
"tierTooltip": "搜索模式需要商业版或企业版套餐。",
@@ -2772,7 +2766,22 @@
},
"rerunFailed": {
"toast": "无法启动检查,请重试。"
},
"shareSaved": {
"toast": "共享已更新。"
},
"shareFailed": {
"toast": "无法更改可使用此账户的人员。请重试。"
},
"onlyMeOwnerOnly": {
"tooltip": "只有添加此账户的人才能将其设为私有。"
},
"shareLocked": {
"tooltip": "只有添加此账户的人或与其共享的管理员可以更改可使用此账户的人员。"
}
},
"accountUnavailable": {
"toast": "此账户已不可用。请选择其他账户。"
}
},
"credentialRequired": {
@@ -9004,9 +9013,6 @@
"description": "关闭后,所有网关端点都会拒绝请求,现有令牌将失效,令牌选择器中也会隐藏网关范围。Craft 沙箱流量由 Craft 设置单独控制。"
}
},
"businessPlanTag": {
"label": "商业版套餐"
},
"tierTooltip": "LLM 网关需要商业版或企业版套餐。",
"scopeNote": {
"title": "每个用户在 设置 → LLM 网关 中配置自己的令牌和连接信息。"
@@ -12094,6 +12100,20 @@
"empty": "此工作表为空。",
"tooLarge": "此工作表过大,无法预览。请下载文件查看。"
}
},
"plans": {
"free": {
"title": "免费版",
"tooltip": "所有版本均可用。"
},
"business": {
"title": "商业版",
"tooltip": "需要[商业版或企业版](/admin/billing)。"
},
"enterprise": {
"title": "企业版",
"tooltip": "需要[企业版](/admin/billing)。"
}
}
},
"craft": {
+70
View File
@@ -0,0 +1,70 @@
"use client";
import { useMemo } from "react";
import { useTranslations } from "next-intl";
import { Tag, type TagProps } from "@opal/components";
import { SvgOrganization, SvgUsers } from "@opal/icons";
import type { IconFunctionComponent, RichStr } from "@opal/types";
import { markdown } from "@opal/utils";
/** The plans a feature can be limited to, lowest first. */
export type Plan = "free" | "business" | "enterprise";
type PlanColor = NonNullable<TagProps["color"]>;
const PLAN_STYLES: Record<
Plan,
{ color: PlanColor; icon: IconFunctionComponent | undefined }
> = {
free: { color: "gray", icon: undefined },
business: { color: "green", icon: SvgUsers },
enterprise: { color: "amber", icon: SvgOrganization },
};
/** Everything that marks a feature as limited to a plan. */
export interface PlanMetadata {
title: string;
icon: IconFunctionComponent | undefined;
color: PlanColor;
/** For a `tag` slot, such as `Content`'s or an option's. */
tag: TagProps;
/** Why the feature is off below this plan, linking to billing. */
tooltip: RichStr;
}
/**
* The app's plan badges and their copy, by plan. Use these wherever a
* feature is limited to a plan, so every such feature reads the same.
*/
export function usePlans(): Record<Plan, PlanMetadata> {
const t = useTranslations("common.plans");
return useMemo(() => {
const metadata = (plan: Plan): PlanMetadata => {
const { color, icon } = PLAN_STYLES[plan];
const title: string = t(`${plan}.title`);
return {
title,
icon,
color,
tag: { title, color, icon },
tooltip: markdown(t(`${plan}.tooltip`)),
};
};
return {
free: metadata("free"),
business: metadata("business"),
enterprise: metadata("enterprise"),
};
}, [t]);
}
export interface PlanTagProps {
plan: Plan;
size?: TagProps["size"];
}
/** A plan's badge. */
export function PlanTag({ plan, size }: PlanTagProps) {
const { tag } = usePlans()[plan];
return <Tag {...tag} size={size} />;
}
@@ -235,11 +235,12 @@ export default function CreateCredential({
and the Create button. */}
<Divider paddingParallel={0} paddingPerpendicular={0} />
{businessTier && (
<Disabled disabled={!formikProps.isValid}>
<ShareAccountField disabled={!formikProps.isValid} />
</Disabled>
)}
<Disabled disabled={!formikProps.isValid}>
<ShareAccountField
disabled={!formikProps.isValid}
groupsLocked={!businessTier}
/>
</Disabled>
<Section flexDirection="row" justifyContent="end">
<CreateButton
@@ -10,6 +10,8 @@ import {
import { InputHorizontal, Section } from "@opal/layouts";
import { SvgLock, SvgUserManage, SvgUsers } from "@opal/icons";
import { useUserGroups } from "@/lib/hooks";
import { usePlans } from "@/lib/app/plans";
import type { CredentialSharing } from "@/lib/credentials/types";
/** Who may reuse a new credential. */
export type ShareAudience = "admins" | "adminsAndGroups" | "onlyMe";
@@ -30,7 +32,10 @@ function isShareAudience(value: string): value is ShareAudience {
}
/** The credential payload's sharing fields for the chosen audience. */
export function shareAccountPayload({ share, groups }: ShareAccountFormValues) {
export function shareAccountPayload({
share,
groups,
}: ShareAccountFormValues): Pick<CredentialSharing, "admin_public" | "groups"> {
return {
admin_public: share !== "onlyMe",
groups: share === "adminsAndGroups" ? groups : [],
@@ -40,40 +45,55 @@ export function shareAccountPayload({ share, groups }: ShareAccountFormValues) {
// The tag id of the admins chip; group tags use their numeric ids.
const ADMINS_TAG_ID = "admins";
interface ShareAccountFieldProps {
/** The Formik path `share` and `groups` live under, e.g. `new_account`. */
namePrefix?: string;
/**
* Locks the selects, as while the account's fields are invalid. The caller
* wraps the field in `Disabled` for the dimmed look.
*/
/**
* The form values a credential's stored sharing reads back as. Admins with no
* groups reads as "Admins": the two are stored the same way.
*/
export function shareAccountValues(credential: {
admin_public: boolean;
groups?: number[];
}): ShareAccountFormValues {
const groups: number[] = credential.groups ?? [];
if (!credential.admin_public) return { share: "onlyMe", groups: [] };
return groups.length > 0
? { share: "adminsAndGroups", groups }
: { share: "admins", groups: [] };
}
interface ShareAccountSelectProps {
value: ShareAccountFormValues;
onChange: (next: ShareAccountFormValues) => void;
disabled: boolean;
/** Turns "Only Me" off, with this as the reason on hover. */
onlyMeDisabledReason?: string;
/** Called when focus leaves the group picker. */
onGroupsBlur?: () => void;
/** Locks "Admins & Groups" with the Business plan badge: groups need it. */
groupsLocked?: boolean;
}
/**
* Who may reuse the new credential: admins (the default), admins and named
* user groups, or only its creator. With groups, a picker below lists them
* after a fixed admins chip, since admins keep access either way.
* Who may reuse a credential: admins (the default), admins and named user
* groups, or only its creator. With groups, a picker below lists them after
* a fixed admins chip, since admins keep access either way.
*/
export function ShareAccountField({
export function ShareAccountSelect({
value,
onChange,
disabled,
namePrefix,
}: ShareAccountFieldProps) {
onlyMeDisabledReason,
onGroupsBlur,
groupsLocked = false,
}: ShareAccountSelectProps) {
const t = useTranslations("admin.credentials.create.share");
const plans = usePlans();
const tGroups = useTranslations("common.groupsMultiSelect");
const { values: formValues, setFieldValue } =
useFormikContext<ShareAccountFormValues>();
const values: ShareAccountFormValues = namePrefix
? getIn(formValues, namePrefix)
: formValues;
const path = (field: keyof ShareAccountFormValues): string =>
namePrefix ? `${namePrefix}.${field}` : field;
const { data: userGroups } = useUserGroups();
const groupNames = new Map(
(userGroups ?? []).map((group) => [group.id, group.name])
);
const groupTags: TagItem[] = values.groups.flatMap((id) => {
const groupTags: TagItem[] = value.groups.flatMap((id) => {
const name = groupNames.get(id);
return name === undefined ? [] : [{ id: String(id), label: name }];
});
@@ -95,9 +115,9 @@ export function ShareAccountField({
center
>
<InputSingleSelect
value={values.share}
value={value.share}
onValueChange={(next) => {
if (isShareAudience(next)) setFieldValue(path("share"), next);
if (isShareAudience(next)) onChange({ ...value, share: next });
}}
defaultOption={DEFAULT_SHARE_AUDIENCE}
placeholder={t("title")}
@@ -115,40 +135,100 @@ export function ShareAccountField({
title: t("adminsAndGroups.label"),
description: t("adminsAndGroups.description"),
icon: SvgUsers,
disabled: groupsLocked,
tag: groupsLocked ? plans.business.tag : undefined,
tooltip: groupsLocked ? plans.business.tooltip : undefined,
},
{
options: [
{ value: "onlyMe", title: t("onlyMe.label"), icon: SvgLock },
{
value: "onlyMe",
title: t("onlyMe.label"),
icon: SvgLock,
disabled: onlyMeDisabledReason !== undefined,
tooltip: onlyMeDisabledReason,
},
],
},
]}
/>
</InputHorizontal>
{values.share === "adminsAndGroups" && (
<InputMultiSelect
tags={[adminsTag, ...groupTags]}
options={(userGroups ?? []).map((group) => ({
value: String(group.id),
title: group.name,
icon: SvgUsers,
}))}
onSelectOption={(option) =>
setFieldValue(path("groups"), [
...values.groups,
Number(option.value),
])
}
onRemoveTag={(id) =>
setFieldValue(
path("groups"),
values.groups.filter((group) => String(group) !== id)
)
}
placeholder={tGroups("userGroups.label")}
disabled={disabled}
/>
{value.share === "adminsAndGroups" && (
// The picker keeps focus on its field while its list is open, so a
// blur here means focus left the picker.
<div
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget)) {
onGroupsBlur?.();
}
}}
>
<InputMultiSelect
tags={[adminsTag, ...groupTags]}
options={(userGroups ?? []).map((group) => ({
value: String(group.id),
title: group.name,
icon: SvgUsers,
}))}
onSelectOption={(option) =>
onChange({
...value,
groups: [...value.groups, Number(option.value)],
})
}
onRemoveTag={(id) =>
onChange({
...value,
groups: value.groups.filter((group) => String(group) !== id),
})
}
placeholder={tGroups("userGroups.label")}
disabled={disabled}
/>
</div>
)}
</Section>
);
}
interface ShareAccountFieldProps {
/** The Formik path `share` and `groups` live under, e.g. `new_account`. */
namePrefix?: string;
/**
* Locks the selects, as while the account's fields are invalid. The caller
* wraps the field in `Disabled` for the dimmed look.
*/
disabled: boolean;
/** Locks "Admins & Groups", as below the Business plan. */
groupsLocked?: boolean;
}
/** {@link ShareAccountSelect} bound to a Formik form's values. */
export function ShareAccountField({
disabled,
namePrefix,
groupsLocked,
}: ShareAccountFieldProps) {
const { values: formValues, setFieldValue } =
useFormikContext<ShareAccountFormValues>();
const values: ShareAccountFormValues = namePrefix
? getIn(formValues, namePrefix)
: formValues;
const path = (field: keyof ShareAccountFormValues): string =>
namePrefix ? `${namePrefix}.${field}` : field;
return (
<ShareAccountSelect
value={values}
onChange={(next) => {
if (next.share !== values.share)
setFieldValue(path("share"), next.share);
if (next.groups !== values.groups)
setFieldValue(path("groups"), next.groups);
}}
disabled={disabled}
groupsLocked={groupsLocked}
/>
);
}
+12
View File
@@ -170,6 +170,18 @@ export function updateCredential(credentialId: number, newDetails: any) {
});
}
/** Changes who may reuse a saved credential: its owner or an admin who can edit it. */
export function updateCredentialSharing(
credentialId: number,
sharing: Pick<CredentialSharing, "admin_public" | "groups">
): Promise<Response> {
return fetch(`/api/manage/admin/credential/${credentialId}/sharing`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(sharing),
});
}
export function updateCredentialWithPrivateKey(
credentialId: number,
newDetails: any,
@@ -42,6 +42,11 @@ export interface Credential<T> extends CredentialBase<T> {
user_personal_name: string | null;
time_created: string;
time_updated: string;
/**
* Whether the requesting user owns the credential. Null when the response
* does not say (credentials nested in pair, group and document-set views).
*/
is_owner?: boolean | null;
}
/**
-27
View File
@@ -1,27 +0,0 @@
import type { TagProps } from "@opal/components";
import { SvgOrganization, SvgUsers } from "@opal/icons";
import type { IconFunctionComponent } from "@opal/types";
export type Plan = "business" | "enterprise";
export const PLAN_CONFIG: Record<
Plan,
{ color: "blue" | "amber"; icon: IconFunctionComponent; title: string }
> = {
business: { color: "blue", icon: SvgUsers, title: "Business Plan" },
enterprise: {
color: "amber",
icon: SvgOrganization,
title: "Enterprise Plan",
},
};
/**
* Returns the `TagProps` that render a subscription-tier badge. Pair with
* any `Tag`-accepting slot (e.g. `Content.tag`, `ContentMd.tag`). To use a
* non-default size, spread and override: `{ ...planTagProps("enterprise"), size: "sm" }`.
*/
export function planTagProps(plan: Plan): TagProps {
const { color, icon, title } = PLAN_CONFIG[plan];
return { color, icon, title };
}
+4 -15
View File
@@ -1,5 +1,6 @@
"use client";
import { usePlans } from "@/lib/app/plans";
import { useAdminRouteTitle } from "@/lib/adminNavLabels";
import { markdown } from "@opal/utils";
import React, {
@@ -38,7 +39,6 @@ import {
SvgExpand,
SvgFold,
SvgExternalLink,
SvgOrganization,
SvgRefreshCw,
SvgRevert,
SvgChevronDown,
@@ -731,6 +731,7 @@ function RetentionField({ value, disabled, onSave }: RetentionFieldProps) {
export default function ChatPreferencesPage() {
const t = useTranslations("admin.chatPreferences");
const plans = usePlans();
const tInputSelect = useTranslations("common.inputSelect");
const adminRouteTitle = useAdminRouteTitle();
const router = useRouter();
@@ -1023,11 +1024,7 @@ export default function ChatPreferencesPage() {
title={t("searchMode.title")}
tag={
!businessTier
? {
title: t("searchMode.businessPlanTag.label"),
color: "amber",
icon: SvgOrganization,
}
? plans.business.tag
: { title: t("betaTag.label"), color: "blue" }
}
description={t("searchMode.description")}
@@ -1489,15 +1486,7 @@ export default function ChatPreferencesPage() {
description={t("retention.description", {
appName: settings.appName,
})}
tag={
!enterpriseTier
? {
title: t("retention.enterprisePlanTag.label"),
color: "amber",
icon: SvgOrganization,
}
: undefined
}
tag={!enterpriseTier ? plans.enterprise.tag : undefined}
disabled={!enterpriseTier}
withLabel
fillInput
@@ -6,7 +6,7 @@ import { SvgPlusCircle, SvgMinusCircle } from "@opal/icons";
import { Button, Card } from "@opal/components";
import { Disabled } from "@opal/core";
import type { RichStr } from "@opal/types";
import { planTagProps } from "@/lib/tier-badge";
import { usePlans } from "@/lib/app/plans";
import { Section } from "@/layouts/general-layouts";
import { InputNumber } from "@opal/components";
import Text from "@/refresh-components/texts/Text";
@@ -44,6 +44,7 @@ function TokenLimitSection({
disabledTooltip,
}: TokenLimitSectionProps) {
const t = useTranslations("admin.groups");
const plans = usePlans();
const [rowKeys, setRowKeys] = useState<{
keys: number[];
nextKey: number;
@@ -119,7 +120,7 @@ function TokenLimitSection({
<Collapsible
title={t("tokenLimits.section.title")}
description={t("tokenLimits.section.description")}
tag={disabled ? { ...planTagProps("enterprise"), size: "sm" } : undefined}
tag={disabled ? { ...plans.enterprise.tag, size: "sm" } : undefined}
>
<Disabled disabled={disabled} tooltip={disabledTooltip}>
<Card border="solid" rounding={4}>
+3 -10
View File
@@ -8,7 +8,7 @@ import { SettingsLayouts, toast } from "@opal/layouts";
import { Content, InputHorizontal } from "@opal/layouts";
import { Card, InputSwitch, MessageCard } from "@opal/components";
import { Disabled } from "@opal/core";
import { SvgOrganization } from "@opal/icons";
import { usePlans } from "@/lib/app/plans";
import { Section } from "@/layouts/general-layouts";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
import { useSettings } from "@/lib/settings/hooks";
@@ -20,6 +20,7 @@ const route = ADMIN_ROUTES.LLM_GATEWAY;
export default function LLMGatewayPage() {
const t = useTranslations("admin.llmGateway");
const plans = usePlans();
const router = useRouter();
const settings = useSettings();
const businessTier = useTierAtLeast(Tier.BUSINESS);
@@ -60,15 +61,7 @@ export default function LLMGatewayPage() {
>
<InputHorizontal
title={t("access.toggle.title")}
tag={
!businessTier
? {
title: t("businessPlanTag.label"),
color: "amber",
icon: SvgOrganization,
}
: undefined
}
tag={!businessTier ? plans.business.tag : undefined}
description={t("access.toggle.description")}
disabled={!businessTier}
withLabel
@@ -79,6 +79,7 @@ import {
useGoogleDriveCredentials,
useResetSavedDraftCredential,
useSavedDraftCredential,
refreshSourceCredentials,
} from "@/lib/credentials/hooks";
import {
NEW_ACCOUNT_FIELD,
@@ -522,7 +523,17 @@ function AddConnectorForm({ connector }: AddConnectorFormProps) {
} else if (!timeoutErrorHappenedRef.current) {
// Only show error if timeout didn't happen
const errorData = await createResponse.json();
toast.error(errorData.detail || errorData.message);
if (
errorData.error_code === "CREDENTIAL_NOT_FOUND" &&
!isDraftCredential(credential)
) {
// The saved account is gone from this user's reach. The
// account section sees it leave the list, says so and drops
// the pick.
await refreshSourceCredentials(connector);
} else {
toast.error(errorData.detail || errorData.message);
}
}
}
@@ -1,15 +1,17 @@
"use client";
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useFormatter, useTranslations } from "next-intl";
import { Disabled } from "@opal/core";
import {
Button,
Card,
Divider,
Modal,
OverflowText,
SelectCard,
Text,
Tooltip,
} from "@opal/components";
import { Content, ContentAction, Section, toast } from "@opal/layouts";
import {
@@ -25,14 +27,23 @@ import {
} from "@opal/icons";
import type {
CredentialCheckReport,
CredentialSharing,
SimilarCredential,
} from "@/lib/credentials/types";
import { useCredentialFieldCopy } from "@/lib/credentials/hooks";
import {
ShareAccountSelect,
shareAccountPayload,
shareAccountValues,
type ShareAccountFormValues,
} from "@/lib/credentials/components/ShareAccountField";
import { getCredentialDetails } from "@/lib/credentials/utils";
import type { ConfigurableSources } from "@/lib/connectors/types/source";
import { IconLoader } from "@opal/loaders";
import { getSourceMetadata } from "@/lib/sources";
import { useUser } from "@/providers/UserProvider";
import { hasPermission } from "@/lib/permissions";
import { Permission } from "@/lib/types";
interface DetailRowProps {
label: string;
@@ -92,6 +103,15 @@ interface AuthenticationAccountCardProps {
checkReport: CredentialCheckReport | null;
/** Starts the checks on this account; resolves once the run is queued. */
onRerunChecks: () => Promise<void>;
/** Locks sharing with groups, which needs the Business plan. */
groupsLocked: boolean;
/**
* Saves who may reuse the account; resolves to what the server stored and
* rejects when the save fails.
*/
onUpdateSharing: (
sharing: Pick<CredentialSharing, "admin_public" | "groups">
) => Promise<{ admin_public: boolean; groups?: number[] }>;
}
/**
* One saved account in the Authentication Account section. The whole card
@@ -110,6 +130,8 @@ export default function AuthenticationAccountCard({
onDelete,
checkReport,
onRerunChecks,
groupsLocked,
onUpdateSharing,
}: AuthenticationAccountCardProps) {
const t = useTranslations("admin.connectorsList.add.account");
const tCredentials = useTranslations("admin.credentials.delete");
@@ -117,7 +139,7 @@ export default function AuthenticationAccountCard({
const fieldCopy = useCredentialFieldCopy(source);
const details = getCredentialDetails(credential, source);
const format = useFormatter();
const { user } = useUser();
const { user, permissions } = useUser();
const [starting, setStarting] = useState<boolean>(false);
const running: boolean = starting || checkReport?.run_status === "running";
const lastChecked: string | null = checkReport?.report?.checked_at ?? null;
@@ -143,6 +165,72 @@ export default function AuthenticationAccountCard({
}
}
const [expanded, setExpanded] = useState<boolean>(false);
// What the server holds, and what the select shows: group edits stay
// local until focus leaves the group picker.
const [savedSharing, setSavedSharing] = useState<ShareAccountFormValues>(() =>
shareAccountValues(credential)
);
const [sharing, setSharing] = useState<ShareAccountFormValues>(savedSharing);
const [savingSharing, setSavingSharing] = useState<boolean>(false);
const hasUnsavedSharing: boolean =
JSON.stringify(sharing) !== JSON.stringify(savedSharing);
// A refetch (e.g. someone else changed it) shows, unless an edit is open.
const serverSharing: string = JSON.stringify({
admin_public: credential.admin_public,
groups: credential.groups ?? [],
});
useEffect(() => {
if (savingSharing || hasUnsavedSharing) return;
const next = shareAccountValues(JSON.parse(serverSharing));
setSavedSharing(next);
setSharing(next);
// Only a new server value resets the select.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [serverSharing]);
// One save at a time, without locking the select: a change made while a
// save runs waits, and only the latest waiting change is sent next.
const savedSharingRef = useRef<ShareAccountFormValues>(savedSharing);
const sharingInFlightRef = useRef<boolean>(false);
const queuedSharingRef = useRef<ShareAccountFormValues | null>(null);
// Group edits not yet sent: they wait for the picker's blur, so a save
// that lands meanwhile must not replace them.
const unsentGroupEditsRef = useRef<boolean>(false);
async function saveSharing(next: ShareAccountFormValues) {
unsentGroupEditsRef.current = false;
setSharing(next);
if (sharingInFlightRef.current) {
queuedSharingRef.current = next;
return;
}
sharingInFlightRef.current = true;
setSavingSharing(true);
let target: ShareAccountFormValues | null = next;
while (target !== null) {
queuedSharingRef.current = null;
try {
const stored = shareAccountValues(
await onUpdateSharing(shareAccountPayload(target))
);
savedSharingRef.current = stored;
setSavedSharing(stored);
// A newer change, sent or not, keeps what the select shows.
if (queuedSharingRef.current === null && !unsentGroupEditsRef.current) {
setSharing(stored);
}
toast.success(t("shareSaved.toast"));
} catch {
if (queuedSharingRef.current === null && !unsentGroupEditsRef.current) {
setSharing(savedSharingRef.current);
}
toast.error(t("shareFailed.toast"));
}
target = queuedSharingRef.current;
}
sharingInFlightRef.current = false;
setSavingSharing(false);
}
const [confirmingDelete, setConfirmingDelete] = useState<boolean>(false);
const addedOn: string = format.dateTime(new Date(credential.time_created), {
@@ -158,7 +246,14 @@ export default function AuthenticationAccountCard({
? null
: (credential.user_personal_name ?? credential.user_email);
const inUse: boolean = credential.usages.length > 0;
const isOwnAccount: boolean = user !== null && credential.user_id === user.id;
const isOwnAccount: boolean =
credential.is_owner ?? (user !== null && credential.user_id === user.id);
// The owner, or an admin when the account is shared with admins or has no
// owner, may change its sharing; a group-shared user may only use it.
const canEditSharing: boolean =
isOwnAccount ||
(hasPermission(permissions, Permission.MANAGE_CONNECTORS) &&
(credential.admin_public || credential.user_id === null));
return (
<>
@@ -208,7 +303,7 @@ export default function AuthenticationAccountCard({
data-testid={`authentication-account-${credential.id}`}
onClick={() => (selected ? onDeselect() : onSelect())}
expandedContent={
<Section padding={2} width="full">
<Section padding={2} gap={2} width="full" alignItems="stretch">
<Card border="none" padding={2} rounding={3}>
<Section gap={3} alignItems="stretch" width="full">
<ContentAction
@@ -272,6 +367,47 @@ export default function AuthenticationAccountCard({
))}
</Section>
</Card>
{/* The one place the flow edits a saved account's sharing. */}
<>
<Divider paddingParallel={2} paddingPerpendicular={0} />
<Tooltip
tooltip={
canEditSharing ? undefined : t("shareLocked.tooltip")
}
>
<div className="p-2">
<ShareAccountSelect
value={sharing}
onChange={(next) => {
// A new audience saves now; a group edit waits for
// focus to leave the group picker. "Admins & Groups"
// with no group is stored as "Admins", so it waits
// for its first group.
if (
next.share !== sharing.share &&
!(
next.share === "adminsAndGroups" &&
next.groups.length === 0
)
) {
void saveSharing(next);
return;
}
unsentGroupEditsRef.current = true;
setSharing(next);
}}
onGroupsBlur={() => {
if (hasUnsavedSharing) void saveSharing(sharing);
}}
disabled={!canEditSharing}
onlyMeDisabledReason={
isOwnAccount ? undefined : t("onlyMeOwnerOnly.tooltip")
}
groupsLocked={groupsLocked}
/>
</div>
</Tooltip>
</>
</Section>
}
>
@@ -34,6 +34,7 @@ import { useSettings } from "@/lib/settings/hooks";
import CreateCredential from "@/lib/credentials/components/CreateCredential";
import { CredentialFieldsRenderer } from "@/lib/credentials/components/CredentialFieldsRenderer";
import { ShareAccountField } from "@/lib/credentials/components/ShareAccountField";
import { updateCredentialSharing } from "@/lib/credentials/svc";
import { getIn, useFormikContext } from "formik";
import { useTierAtLeast } from "@/hooks/useTierAtLeast";
import { Tier } from "@/lib/settings/types";
@@ -175,7 +176,31 @@ export default function AuthenticationAccountSection({
if (currentCredential !== null) onCredentialChange(null);
}, [newAccountKey, currentCredential, onCredentialChange]);
// A picked account that drops out of the list was un-shared or deleted by
// someone else: say so and drop the pick. Only an account the list has
// shown counts, so a just-created pick waits for the list to catch up.
const pickSeenRef = useRef<number | null>(null);
const deletingIdRef = useRef<number | null>(null);
useEffect(() => {
if (credentials === undefined || selectedSavedId === null) return;
if (credentials.some((credential) => credential.id === selectedSavedId)) {
pickSeenRef.current = selectedSavedId;
return;
}
if (
pickSeenRef.current !== selectedSavedId ||
deletingIdRef.current === selectedSavedId
) {
return;
}
pickSeenRef.current = null;
toast.error(t("add.accountUnavailable.toast"));
onCredentialChange(null);
}, [credentials, selectedSavedId, onCredentialChange, t]);
async function onDeleteCredential(credential: Credential<any | null>) {
// The user's own delete is not an account going missing.
deletingIdRef.current = credential.id;
const error = await remove(credential, t("add.unknownError.toast"));
if (error === null) {
// A deleted account cannot stay picked.
@@ -183,6 +208,7 @@ export default function AuthenticationAccountSection({
} else {
toast.error(error);
}
deletingIdRef.current = null;
}
async function onSwap(selectedCredential: Credential<any>) {
@@ -258,12 +284,11 @@ export default function AuthenticationAccountSection({
)}
/>
</InputVertical>
{businessTier && (
<ShareAccountField
namePrefix={NEW_ACCOUNT_FIELD}
disabled={!newAccountReady}
/>
)}
<ShareAccountField
namePrefix={NEW_ACCOUNT_FIELD}
disabled={!newAccountReady}
groupsLocked={!businessTier}
/>
</Section>
</Disabled>
</Section>
@@ -346,6 +371,17 @@ export default function AuthenticationAccountSection({
onDelete={() => onDeleteCredential(credential)}
checkReport={checkReports.reportFor(credential.id)}
onRerunChecks={() => checkReports.rerun(credential.id)}
groupsLocked={!businessTier}
onUpdateSharing={async (sharing) => {
const response = await updateCredentialSharing(
credential.id,
sharing
);
if (!response.ok) throw new Error(await response.text());
const saved: SimilarCredential = await response.json();
refresh();
return saved;
}}
/>
);
}