chore(settings): address review comments from PR 21072 (#21121)
## Summary Round through bosiraphael's 31 review threads on the merged PR #21072 (discovery hero + ephemeral playground token). The user asked to apply each suggestion only where it adds value, so this PR is split into three buckets. ### Comments (~17 threads) - Tightened security-rationale / CSS-gotcha / API-doc comments to one or two factual lines - Kept (shortened) the comments above `RequireAccessTokenGuard` call sites — without them a future reader could remove the guard and silently reopen the escalation hole - Kept (shortened) the in-memory-only rationale on `playgroundApiKeyState` for the same reason - Kept `flex: 1 + min-height: 0` CSS gotcha on `SubMenuTopBarContainer` — non-obvious and easy to break ### Structure / extraction - Move `WEBHOOK_TABLE_ROW_GRID_TEMPLATE_COLUMNS` to its own constants file (one-export-per-file) - Split `SettingsAgentToolsTab` and `SettingsAgentToolsTable` across queries/, hooks/, types/, utils/: - `graphql/queries/findManyApplicationsForToolTable.ts` - `graphql/queries/findManyMarketplaceAppsForToolTable.ts` - `hooks/useSettingsAgentToolsTable.ts` (data loading + index merging) - `types/SettingsAgentToolItem|Application|MarketplaceApp` - `utils/getToolApplicationId|getToolLink` - Extract `SettingsAiModelsTab` optimistic mutations into `hooks/useSettingsAiModelsActions` (handleModelFieldChange, handleUseRecommendedToggle, handleModelToggle, handleToggleAllVisibleModels) - Extract `SettingsAI.handleCreateTool` into `hooks/useCreateTool` - Drop unnecessary `useMemo` wrappers on `heroTabs` arrays (SettingsObjects, SettingsLayout) - Simplify `MenuItemToggle` handler in SettingsAgentSkillsTab: `onToggleChange={setShowDeactivated}` (no longer wrapping with arrow + read of stale `!showDeactivated`) ### Hero assets - Replace placeholder `customize-illustration` with per-page exports - Rename `layout/customize-illustration-{light,dark}.png` → `layout/cover-{light,dark}.png` - Add `cover-{light,dark}.png` for **applications** and **members** (they were both pointing at the layout placeholder as a TODO) - Overwrite `data-model/cover-*.png`, `playground/cover-*.png`, `ai/ai-tools-cover-*.png` with the new exports ## Test plan - [ ] `npx nx typecheck twenty-front` ✅ - [ ] `npx nx typecheck twenty-server` ✅ - [ ] `npx nx lint twenty-front` ✅ (oxlint + oxfmt, 0 warnings/errors) - [ ] `/settings/layout`, `/settings/data-model`, `/settings/applications`, `/settings/ai`, `/settings/api-webhooks`, `/settings/members` each render the new hero illustration (light + dark) - [ ] AI tab: tool list still loads, search + Custom/Managed/Standard filters still work, "New Tool" still navigates to detail - [ ] AI tab: Models tab — smart/fast model select, "Use best models only" toggle, per-model checkboxes, toggle-all all still optimistic+revert on error - [ ] Skills tab: "Deactivated" toggle still flips show/hide - [ ] Webhooks table still uses the 1fr 28px grid
@@ -1779,6 +1779,7 @@ enum FeatureFlagKey {
|
||||
IS_EMAIL_GROUP_ENABLED
|
||||
IS_JUNCTION_RELATIONS_ENABLED
|
||||
IS_REST_METADATA_API_NEW_FORMAT_DIRECT
|
||||
IS_SETTINGS_DISCOVERY_HERO_ENABLED
|
||||
}
|
||||
|
||||
type WorkspaceUrls {
|
||||
|
||||
@@ -1411,7 +1411,7 @@ export interface FeatureFlag {
|
||||
__typename: 'FeatureFlag'
|
||||
}
|
||||
|
||||
export type FeatureFlagKey = 'IS_UNIQUE_INDEXES_ENABLED' | 'IS_JSON_FILTER_ENABLED' | 'IS_MARKETPLACE_SETTING_TAB_VISIBLE' | 'IS_PUBLIC_DOMAIN_ENABLED' | 'IS_EMAIL_GROUP_ENABLED' | 'IS_JUNCTION_RELATIONS_ENABLED' | 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT'
|
||||
export type FeatureFlagKey = 'IS_UNIQUE_INDEXES_ENABLED' | 'IS_JSON_FILTER_ENABLED' | 'IS_MARKETPLACE_SETTING_TAB_VISIBLE' | 'IS_PUBLIC_DOMAIN_ENABLED' | 'IS_EMAIL_GROUP_ENABLED' | 'IS_JUNCTION_RELATIONS_ENABLED' | 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT' | 'IS_SETTINGS_DISCOVERY_HERO_ENABLED'
|
||||
|
||||
export interface WorkspaceUrls {
|
||||
customUrl?: Scalars['String']
|
||||
@@ -8732,7 +8732,8 @@ export const enumFeatureFlagKey = {
|
||||
IS_PUBLIC_DOMAIN_ENABLED: 'IS_PUBLIC_DOMAIN_ENABLED' as const,
|
||||
IS_EMAIL_GROUP_ENABLED: 'IS_EMAIL_GROUP_ENABLED' as const,
|
||||
IS_JUNCTION_RELATIONS_ENABLED: 'IS_JUNCTION_RELATIONS_ENABLED' as const,
|
||||
IS_REST_METADATA_API_NEW_FORMAT_DIRECT: 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT' as const
|
||||
IS_REST_METADATA_API_NEW_FORMAT_DIRECT: 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT' as const,
|
||||
IS_SETTINGS_DISCOVERY_HERO_ENABLED: 'IS_SETTINGS_DISCOVERY_HERO_ENABLED' as const
|
||||
}
|
||||
|
||||
export const enumIdentityProviderType = {
|
||||
|
||||
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 70 KiB |
@@ -289,6 +289,7 @@ export enum FeatureFlagKey {
|
||||
IS_MARKETPLACE_SETTING_TAB_VISIBLE = 'IS_MARKETPLACE_SETTING_TAB_VISIBLE',
|
||||
IS_PUBLIC_DOMAIN_ENABLED = 'IS_PUBLIC_DOMAIN_ENABLED',
|
||||
IS_REST_METADATA_API_NEW_FORMAT_DIRECT = 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT',
|
||||
IS_SETTINGS_DISCOVERY_HERO_ENABLED = 'IS_SETTINGS_DISCOVERY_HERO_ENABLED',
|
||||
IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED'
|
||||
}
|
||||
|
||||
|
||||
@@ -1641,6 +1641,7 @@ export enum FeatureFlagKey {
|
||||
IS_MARKETPLACE_SETTING_TAB_VISIBLE = 'IS_MARKETPLACE_SETTING_TAB_VISIBLE',
|
||||
IS_PUBLIC_DOMAIN_ENABLED = 'IS_PUBLIC_DOMAIN_ENABLED',
|
||||
IS_REST_METADATA_API_NEW_FORMAT_DIRECT = 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT',
|
||||
IS_SETTINGS_DISCOVERY_HERO_ENABLED = 'IS_SETTINGS_DISCOVERY_HERO_ENABLED',
|
||||
IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED'
|
||||
}
|
||||
|
||||
|
||||
@@ -24,9 +24,6 @@ export const useEnterLayoutCustomizationMode = () => {
|
||||
const { navigateSidePanel } = useNavigateSidePanel();
|
||||
const { enqueueWarningSnackBar } = useSnackBar();
|
||||
|
||||
// Returns whether customization mode is active afterward, so callers that
|
||||
// navigate on entry can skip navigation when entry was blocked (e.g. a
|
||||
// dashboard is mid-edit).
|
||||
const enterLayoutCustomizationMode = useCallback((): boolean => {
|
||||
const isLayoutCustomizationModeAlreadyEnabled = store.get(
|
||||
isLayoutCustomizationModeEnabledState.atom,
|
||||
|
||||
@@ -4,11 +4,13 @@ import {
|
||||
} from '@/settings/components/SettingsCustomizeVideoModal';
|
||||
import { HeroPlayButton } from '@/ui/layout/hero/components/HeroPlayButton';
|
||||
import { useModal } from '@/ui/layout/modal/hooks/useModal';
|
||||
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
||||
import { styled } from '@linaria/react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useContext } from 'react';
|
||||
import { Card } from 'twenty-ui/layout';
|
||||
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { FeatureFlagKey } from '~/generated-metadata/graphql';
|
||||
|
||||
const COVER_HEIGHT = 150;
|
||||
|
||||
@@ -56,6 +58,9 @@ export const SettingsDiscoveryHeroCard = ({
|
||||
const { t } = useLingui();
|
||||
const { colorScheme } = useContext(ThemeContext);
|
||||
const { openModal } = useModal();
|
||||
const isDiscoveryVideoEnabled = useIsFeatureEnabled(
|
||||
FeatureFlagKey.IS_SETTINGS_DISCOVERY_HERO_ENABLED,
|
||||
);
|
||||
|
||||
const modalInstanceId = `${instanceIdPrefix}-modal`;
|
||||
const tabsInstanceId = `${instanceIdPrefix}-tabs`;
|
||||
@@ -67,19 +72,23 @@ export const SettingsDiscoveryHeroCard = ({
|
||||
<Card rounded>
|
||||
<StyledCoverContainer>
|
||||
<StyledImage src={src} alt="" aria-hidden />
|
||||
<StyledOverlay>
|
||||
<HeroPlayButton
|
||||
onClick={() => openModal(modalInstanceId)}
|
||||
ariaLabel={playButtonAriaLabel ?? t`Watch demo`}
|
||||
/>
|
||||
</StyledOverlay>
|
||||
{isDiscoveryVideoEnabled && (
|
||||
<StyledOverlay>
|
||||
<HeroPlayButton
|
||||
onClick={() => openModal(modalInstanceId)}
|
||||
ariaLabel={playButtonAriaLabel ?? t`Watch demo`}
|
||||
/>
|
||||
</StyledOverlay>
|
||||
)}
|
||||
</StyledCoverContainer>
|
||||
</Card>
|
||||
<SettingsCustomizeVideoModal
|
||||
modalInstanceId={modalInstanceId}
|
||||
tabsInstanceId={tabsInstanceId}
|
||||
tabs={tabs}
|
||||
/>
|
||||
{isDiscoveryVideoEnabled && (
|
||||
<SettingsCustomizeVideoModal
|
||||
modalInstanceId={modalInstanceId}
|
||||
tabsInstanceId={tabsInstanceId}
|
||||
tabs={tabs}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,15 +6,10 @@ import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
export type SettingsStatRow = {
|
||||
Icon: IconComponent;
|
||||
label: string;
|
||||
// String so callers can render a placeholder (e.g. "—") while async counts
|
||||
// are still loading. Layout stats just pass `count.toString()`.
|
||||
value: string;
|
||||
};
|
||||
|
||||
type SettingsStatsGridProps = {
|
||||
// Each inner array is one column rendered top-to-bottom; columns are
|
||||
// separated by a vertical divider. Pass [[a, b], [c, d]] for a 2x2 layout
|
||||
// or [[a, b, c]] for a single column.
|
||||
columns: SettingsStatRow[][];
|
||||
};
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 40 KiB |
@@ -3,12 +3,8 @@ import { styled } from '@linaria/react';
|
||||
import { MOBILE_VIEWPORT, themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import React from 'react';
|
||||
|
||||
// Column width used by the Applications data tables (Instances column).
|
||||
export const SETTINGS_OBJECT_TABLE_COLUMN_WIDTH = '98.7px';
|
||||
|
||||
// Relative grid: Name takes all remaining space (with a floor); App / Fields /
|
||||
// Instances get fixed minimums so short text columns don't collapse; trailing
|
||||
// 36 px holds the chevron / action cell.
|
||||
export const SETTINGS_OBJECT_TABLE_ROW_GRID_TEMPLATE_COLUMNS = `minmax(180px, 1fr) 140px 80px 100px 36px`;
|
||||
|
||||
export const SETTINGS_OBJECT_TABLE_ROW_MOBILE_MIN_WIDTH = '520px';
|
||||
|
||||
@@ -58,7 +58,6 @@ export const ObjectLayout = ({ objectMetadataItem }: ObjectLayoutProps) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip navigation when entry was blocked (e.g. a dashboard is mid-edit).
|
||||
if (!enterLayoutCustomizationMode()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { styled } from '@linaria/react';
|
||||
|
||||
import { WEBHOOK_TABLE_ROW_GRID_TEMPLATE_COLUMNS } from '@/settings/developers/constants/WebhookTableRowGridTemplateColumns';
|
||||
import { TableCell } from '@/ui/layout/table/components/TableCell';
|
||||
import { TableRow } from '@/ui/layout/table/components/TableRow';
|
||||
import { getUrlHostnameOrThrow, isValidUrl } from 'twenty-shared/utils';
|
||||
@@ -11,8 +12,6 @@ import { useContext } from 'react';
|
||||
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { type Webhook } from '~/generated-metadata/graphql';
|
||||
|
||||
export const WEBHOOK_TABLE_ROW_GRID_TEMPLATE_COLUMNS = '1fr 28px';
|
||||
|
||||
const StyledIconChevronRightContainer = styled.span`
|
||||
align-items: center;
|
||||
color: ${themeCssVariables.font.color.tertiary};
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { styled } from '@linaria/react';
|
||||
|
||||
import {
|
||||
SettingsDevelopersWebhookTableRow,
|
||||
WEBHOOK_TABLE_ROW_GRID_TEMPLATE_COLUMNS,
|
||||
} from '@/settings/developers/components/SettingsDevelopersWebhookTableRow';
|
||||
import { SettingsDevelopersWebhookTableRow } from '@/settings/developers/components/SettingsDevelopersWebhookTableRow';
|
||||
import { WEBHOOK_TABLE_ROW_GRID_TEMPLATE_COLUMNS } from '@/settings/developers/constants/WebhookTableRowGridTemplateColumns';
|
||||
import { Table } from '@/ui/layout/table/components/Table';
|
||||
import { TableBody } from '@/ui/layout/table/components/TableBody';
|
||||
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const WEBHOOK_TABLE_ROW_GRID_TEMPLATE_COLUMNS = '1fr 28px';
|
||||
|
Before Width: | Height: | Size: 288 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 290 KiB After Width: | Height: | Size: 111 KiB |
@@ -19,9 +19,6 @@ const playgroundSetupFormSchema = z.object({
|
||||
|
||||
type PlaygroundSetupFormValues = z.infer<typeof playgroundSetupFormSchema>;
|
||||
|
||||
// Last column shrinks to the Launch button's content width so its right
|
||||
// edge sits at the form's right edge. The two select columns share the
|
||||
// remaining space equally.
|
||||
const StyledForm = styled.form`
|
||||
align-items: end;
|
||||
display: grid;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { PlaygroundSetupForm } from '@/settings/playground/components/PlaygroundSetupForm';
|
||||
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
|
||||
import { type Meta, type StoryObj } from '@storybook/react-vite';
|
||||
import { ComponentDecorator, RouterDecorator } from 'twenty-ui/testing';
|
||||
|
||||
const meta: Meta<typeof PlaygroundSetupForm> = {
|
||||
title: 'Modules/Settings/Playground/PlaygroundSetupForm',
|
||||
component: PlaygroundSetupForm,
|
||||
decorators: [ComponentDecorator, RouterDecorator],
|
||||
decorators: [ComponentDecorator, RouterDecorator, SnackBarDecorator],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
|
||||
@@ -2,17 +2,13 @@ import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomStat
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { type AuthToken } from '~/generated-metadata/graphql';
|
||||
|
||||
// In-memory only: a short-lived, full-permission bearer token. Keeping it out of
|
||||
// localStorage bounds the exfiltration window to the current tab and leaves no
|
||||
// usable credential at rest after the tab closes.
|
||||
// In-memory only — never persist this bearer token to localStorage.
|
||||
export const playgroundApiKeyState = createAtomState<AuthToken | null>({
|
||||
key: 'playgroundApiKeyState',
|
||||
defaultValue: null,
|
||||
});
|
||||
|
||||
// Usable only while it stays valid for at least `bufferMs` longer. Consumers pass
|
||||
// no buffer (reject the moment it expires); the launcher passes a buffer so it
|
||||
// re-mints before a near-expired token can fail mid-session.
|
||||
// Returns true when the token is still valid `bufferMs` from now.
|
||||
export const isPlaygroundApiKeyFresh = (
|
||||
token: AuthToken | null,
|
||||
bufferMs = 0,
|
||||
|
||||
@@ -20,9 +20,6 @@ type SubMenuTopBarContainerProps = {
|
||||
tag?: JSX.Element;
|
||||
};
|
||||
|
||||
// Cards, forms, and tables inside the white panel are centered in a fixed
|
||||
// max-width column so they don't sprawl on large displays. The white panel
|
||||
// itself spans edge-to-edge; only the content is constrained.
|
||||
const SETTINGS_CONTENT_MAX_WIDTH = 760;
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
@@ -31,10 +28,8 @@ const StyledContainer = styled.div`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
// flex: 1 + min-height: 0 keep the vertical-scroll chain intact: PagePanel's
|
||||
// own overflow handling sits one level up and depends on its children
|
||||
// participating in the flex height calculation rather than collapsing to
|
||||
// content height.
|
||||
// flex: 1 + min-height: 0 are required for PagePanel's overflow chain — the
|
||||
// child must participate in the flex height calc rather than collapse to content.
|
||||
const StyledBodyContentWrapper = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
@@ -72,12 +67,6 @@ export const SubMenuTopBarContainer = ({
|
||||
<PageHeader title={<Breadcrumb links={links} />}>
|
||||
{actionButton}
|
||||
</PageHeader>
|
||||
{/*
|
||||
MainContainerLayoutWithSidePanel is the same wrapper the App's record
|
||||
pages use: it renders the page body on the left and SidePanelForDesktop
|
||||
on the right. Hosting it here lets the AI chat side panel (and any
|
||||
other side-panel page) open in settings exactly as it does in the App.
|
||||
*/}
|
||||
<MainContainerLayoutWithSidePanel>
|
||||
<StyledBodyContentWrapper>
|
||||
<InformationBannerWrapper />
|
||||
|
||||
@@ -5,24 +5,13 @@ import { type ReactNode } from 'react';
|
||||
import { AnimatedExpandableContainer } from 'twenty-ui/layout';
|
||||
|
||||
type CollapsibleNavigationDrawerSectionProps = {
|
||||
// Unique id used to persist the open/closed state in localStorage. Pass
|
||||
// a namespaced value (e.g. 'settings/User') so unrelated sections in
|
||||
// different drawers don't share state.
|
||||
// Namespaced id (e.g. 'settings/User') used to persist open/closed state.
|
||||
sectionId: string;
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
// Optional wrapper around the section title (e.g. AdvancedSettingsWrapper
|
||||
// for advanced-mode-only sections). Receives the title node and returns
|
||||
// the wrapped node.
|
||||
wrapTitle?: (titleNode: ReactNode) => ReactNode;
|
||||
};
|
||||
|
||||
// One-stop section component for any drawer that wants the main-app's
|
||||
// collapsible section behavior: click the title to collapse / expand,
|
||||
// animated height transition, persisted open state, chevron-on-hover.
|
||||
// Use this instead of stitching together NavigationDrawerSection +
|
||||
// NavigationDrawerSectionTitle + AnimatedExpandableContainer by hand at
|
||||
// every call site.
|
||||
export const CollapsibleNavigationDrawerSection = ({
|
||||
sectionId,
|
||||
label,
|
||||
|
||||
@@ -5,8 +5,6 @@ import { styled } from '@linaria/react';
|
||||
import { useIsMobile } from 'twenty-ui/utilities';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
// Mobile keeps the touch-friendly horizontal padding; on desktop the container
|
||||
// is edge-to-edge and the child supplies its own padding.
|
||||
const StyledFixedContainer = styled.div<{ isMobile?: boolean }>`
|
||||
padding-left: ${({ isMobile }) =>
|
||||
isMobile ? themeCssVariables.spacing[5] : '0'};
|
||||
|
||||
@@ -52,9 +52,6 @@ export type NavigationDrawerItemProps = {
|
||||
onClick?: () => void;
|
||||
Icon?: IconComponent | ((props: TablerIconsProps) => JSX.Element);
|
||||
iconColor?: string | null;
|
||||
// Wrap the plain icon in a soft grey tile (no border) — used by the
|
||||
// settings drawer so its icons read as a uniform group without picking
|
||||
// up TintedIconTile's bordered colored treatment.
|
||||
withIconBackground?: boolean;
|
||||
active?: boolean;
|
||||
modifier?: NavigationDrawerItemModifier;
|
||||
@@ -206,10 +203,6 @@ const StyledIcon = styled.div`
|
||||
margin-right: ${themeCssVariables.spacing[2]};
|
||||
`;
|
||||
|
||||
// Soft grey background-only tile (no border) used by the settings drawer.
|
||||
// Sized one step larger than the icon so the icon sits with a couple of
|
||||
// pixels of breathing room on every side. radius.md matches the rest of
|
||||
// the App's small-card / tile language; radius.sm read as sharp squares.
|
||||
const StyledIconBackgroundTile = styled.div`
|
||||
align-items: center;
|
||||
background-color: ${themeCssVariables.background.tertiary};
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { usePersistLogicFunction } from '@/logic-functions/hooks/usePersistLogicFunction';
|
||||
import { SettingsDiscoveryHeroCard } from '@/settings/components/SettingsDiscoveryHeroCard';
|
||||
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
|
||||
import { TabList } from '@/ui/layout/tab-list/components/TabList';
|
||||
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
|
||||
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
|
||||
import { getSettingsPath } from 'twenty-shared/utils';
|
||||
|
||||
import { t } from '@lingui/core/macro';
|
||||
import {
|
||||
@@ -30,6 +25,7 @@ import { SettingsAiModelsTab } from '~/pages/settings/ai/components/SettingsAiMo
|
||||
import { SettingsAiOverviewTab } from '~/pages/settings/ai/components/SettingsAiOverviewTab';
|
||||
import { SettingsAiUsageTab } from '~/pages/settings/ai/components/SettingsAiUsageTab';
|
||||
import { SETTINGS_AI_TABS } from '~/pages/settings/ai/constants/SettingsAiTabs';
|
||||
import { useCreateTool } from '~/pages/settings/ai/hooks/useCreateTool';
|
||||
|
||||
const AI_HERO_LIGHT = '/images/ai/ai-tools-cover-light.png';
|
||||
const AI_HERO_DARK = '/images/ai/ai-tools-cover-dark.png';
|
||||
@@ -37,55 +33,13 @@ const AI_HERO_DARK = '/images/ai/ai-tools-cover-dark.png';
|
||||
const SETTINGS_AI_HERO_INSTANCE_ID_PREFIX = 'settings-ai-hero';
|
||||
|
||||
export const SettingsAI = () => {
|
||||
const navigate = useNavigate();
|
||||
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
|
||||
const { createLogicFunction } = usePersistLogicFunction();
|
||||
const [isCreatingTool, setIsCreatingTool] = useState(false);
|
||||
const { handleCreateTool, isCreatingTool } = useCreateTool();
|
||||
|
||||
const activeTabId = useAtomComponentStateValue(
|
||||
activeTabIdComponentState,
|
||||
SETTINGS_AI_TABS.COMPONENT_INSTANCE_ID,
|
||||
);
|
||||
|
||||
const handleCreateTool = async () => {
|
||||
setIsCreatingTool(true);
|
||||
try {
|
||||
const result = await createLogicFunction({
|
||||
input: {
|
||||
name: 'new-tool',
|
||||
toolTriggerSettings: {
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (result.status === 'successful' && isDefined(result.response?.data)) {
|
||||
const newLogicFunction = result.response.data.createOneLogicFunction;
|
||||
enqueueSuccessSnackBar({ message: t`Tool created` });
|
||||
|
||||
const applicationId = newLogicFunction.applicationId;
|
||||
if (isDefined(applicationId)) {
|
||||
navigate(
|
||||
getSettingsPath(SettingsPath.ApplicationLogicFunctionDetail, {
|
||||
applicationId,
|
||||
logicFunctionId: newLogicFunction.id,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
navigate(
|
||||
getSettingsPath(SettingsPath.LogicFunctionDetail, {
|
||||
logicFunctionId: newLogicFunction.id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
enqueueErrorSnackBar({ message: t`Failed to create tool` });
|
||||
}
|
||||
} finally {
|
||||
setIsCreatingTool(false);
|
||||
}
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
id: SETTINGS_AI_TABS.TABS_IDS.OVERVIEW,
|
||||
|
||||
@@ -104,7 +104,7 @@ export const SettingsAgentSkillsTab = () => {
|
||||
<DropdownMenuItemsContainer>
|
||||
<MenuItemToggle
|
||||
LeftIcon={IconArchive}
|
||||
onToggleChange={() => setShowDeactivated(!showDeactivated)}
|
||||
onToggleChange={setShowDeactivated}
|
||||
toggled={showDeactivated}
|
||||
text={t`Deactivated`}
|
||||
toggleSize="small"
|
||||
|
||||
@@ -1,80 +1,35 @@
|
||||
import { gql } from '@apollo/client';
|
||||
import { useQuery } from '@apollo/client/react';
|
||||
import { styled } from '@linaria/react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { type ReactNode, useMemo, useState } from 'react';
|
||||
import { type ReactNode, useState } from 'react';
|
||||
|
||||
import { useGetToolIndex } from '@/ai/hooks/useGetToolIndex';
|
||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { logicFunctionsSelector } from '@/logic-functions/states/logicFunctionsSelector';
|
||||
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
|
||||
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
|
||||
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { ToolCategory } from 'twenty-shared/ai';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { H2Title, IconLock, IconPuzzle, IconTool } from 'twenty-ui/display';
|
||||
import { SearchInput } from 'twenty-ui/input';
|
||||
import { Section } from 'twenty-ui/layout';
|
||||
import { MenuItemToggle } from 'twenty-ui/navigation';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import {
|
||||
type SettingsAgentToolItem,
|
||||
SettingsAgentToolsTable,
|
||||
} from '~/pages/settings/ai/components/SettingsAgentToolsTable';
|
||||
import { SettingsAgentToolsTable } from '~/pages/settings/ai/components/SettingsAgentToolsTable';
|
||||
import { useSettingsAgentToolsTable } from '~/pages/settings/ai/hooks/useSettingsAgentToolsTable';
|
||||
import { type SettingsAgentToolItem } from '~/pages/settings/ai/types/SettingsAgentToolItem';
|
||||
import { normalizeSearchText } from '~/utils/normalizeSearchText';
|
||||
|
||||
const FIND_MANY_APPLICATIONS_FOR_TOOL_TABLE = gql`
|
||||
query FindManyApplicationsForToolTable {
|
||||
findManyApplications {
|
||||
id
|
||||
name
|
||||
universalIdentifier
|
||||
logo
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const FIND_MANY_MARKETPLACE_APPS_FOR_TOOL_TABLE = gql`
|
||||
query FindManyMarketplaceAppsForToolTable {
|
||||
findManyMarketplaceApps {
|
||||
id
|
||||
universalIdentifier
|
||||
icon
|
||||
logo
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledSearchContainer = styled.div`
|
||||
padding-bottom: ${themeCssVariables.spacing[2]};
|
||||
`;
|
||||
|
||||
export const SettingsAgentToolsTab = () => {
|
||||
const logicFunctions = useAtomStateValue(logicFunctionsSelector);
|
||||
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
|
||||
const {
|
||||
toolIndex,
|
||||
loading: toolIndexLoading,
|
||||
error: toolIndexError,
|
||||
} = useGetToolIndex();
|
||||
const { data: applicationsData } = useQuery<{
|
||||
findManyApplications: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
universalIdentifier: string;
|
||||
logo?: string | null;
|
||||
}>;
|
||||
}>(FIND_MANY_APPLICATIONS_FOR_TOOL_TABLE);
|
||||
const { data: marketplaceAppsData } = useQuery<{
|
||||
findManyMarketplaceApps: Array<{
|
||||
id: string;
|
||||
universalIdentifier: string;
|
||||
icon: string;
|
||||
logo?: string | null;
|
||||
}>;
|
||||
}>(FIND_MANY_MARKETPLACE_APPS_FOR_TOOL_TABLE);
|
||||
const { t } = useLingui();
|
||||
const {
|
||||
allTools,
|
||||
applicationById,
|
||||
marketplaceAppByUniversalIdentifier,
|
||||
currentWorkspace,
|
||||
isLoading,
|
||||
} = useSettingsAgentToolsTable();
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [showCustomTools, setShowCustomTools] = useState(true);
|
||||
const [showManagedTools, setShowManagedTools] = useState(true);
|
||||
@@ -89,42 +44,6 @@ export const SettingsAgentToolsTab = () => {
|
||||
const isCustom = (tool: SettingsAgentToolItem) =>
|
||||
isDefined(tool.applicationId);
|
||||
|
||||
const allTools: SettingsAgentToolItem[] = useMemo(
|
||||
() => [
|
||||
...logicFunctions
|
||||
.filter((fn) => isDefined(fn.toolTriggerSettings))
|
||||
.map((fn) => ({
|
||||
identifier: fn.id,
|
||||
name: fn.name,
|
||||
description: fn.description,
|
||||
applicationId: fn.applicationId,
|
||||
})),
|
||||
...toolIndex
|
||||
.filter((tool) => tool.category !== ToolCategory.LOGIC_FUNCTION)
|
||||
.map((tool) => ({
|
||||
identifier: tool.name,
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
category: tool.category,
|
||||
objectName: tool.objectName,
|
||||
icon: tool.icon,
|
||||
})),
|
||||
],
|
||||
[logicFunctions, toolIndex],
|
||||
);
|
||||
|
||||
const applicationById = new Map(
|
||||
(applicationsData?.findManyApplications ?? []).map((application) => [
|
||||
application.id,
|
||||
application,
|
||||
]),
|
||||
);
|
||||
const marketplaceAppByUniversalIdentifier = new Map(
|
||||
(marketplaceAppsData?.findManyMarketplaceApps ?? []).map(
|
||||
(marketplaceApp) => [marketplaceApp.universalIdentifier, marketplaceApp],
|
||||
),
|
||||
);
|
||||
|
||||
const filteredTools = allTools
|
||||
.filter((tool) => {
|
||||
const searchNormalized = normalizeSearchText(searchTerm);
|
||||
@@ -149,8 +68,6 @@ export const SettingsAgentToolsTab = () => {
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const isLoading = toolIndexLoading && !toolIndexError;
|
||||
|
||||
return (
|
||||
<Section>
|
||||
<H2Title
|
||||
@@ -173,27 +90,21 @@ export const SettingsAgentToolsTab = () => {
|
||||
<DropdownMenuItemsContainer>
|
||||
<MenuItemToggle
|
||||
LeftIcon={IconTool}
|
||||
onToggleChange={() =>
|
||||
setShowCustomTools(!showCustomTools)
|
||||
}
|
||||
onToggleChange={setShowCustomTools}
|
||||
toggled={showCustomTools}
|
||||
text={t`Custom`}
|
||||
toggleSize="small"
|
||||
/>
|
||||
<MenuItemToggle
|
||||
LeftIcon={IconLock}
|
||||
onToggleChange={() =>
|
||||
setShowManagedTools(!showManagedTools)
|
||||
}
|
||||
onToggleChange={setShowManagedTools}
|
||||
toggled={showManagedTools}
|
||||
text={t`Managed`}
|
||||
toggleSize="small"
|
||||
/>
|
||||
<MenuItemToggle
|
||||
LeftIcon={IconPuzzle}
|
||||
onToggleChange={() =>
|
||||
setShowStandardTools(!showStandardTools)
|
||||
}
|
||||
onToggleChange={setShowStandardTools}
|
||||
toggled={showStandardTools}
|
||||
text={t`Standard`}
|
||||
toggleSize="small"
|
||||
|
||||
@@ -1,53 +1,34 @@
|
||||
import { useContext } from 'react';
|
||||
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
|
||||
|
||||
import { type CurrentWorkspace } from '@/auth/states/currentWorkspaceState';
|
||||
import { Table } from '@/ui/layout/table/components/Table';
|
||||
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
|
||||
import { TableRow } from '@/ui/layout/table/components/TableRow';
|
||||
import { styled } from '@linaria/react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { TWENTY_STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER } from 'twenty-shared/application';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { IconChevronRight } from 'twenty-ui/display';
|
||||
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
import { type CurrentWorkspace } from '@/auth/states/currentWorkspaceState';
|
||||
import { SettingsToolIcon } from '~/pages/settings/ai/components/SettingsToolIcon';
|
||||
import {
|
||||
SettingsToolTableRow,
|
||||
TOOL_TABLE_ROW_GRID_TEMPLATE_COLUMNS,
|
||||
} from '~/pages/settings/ai/components/SettingsToolTableRow';
|
||||
|
||||
export type SettingsAgentToolItem = {
|
||||
identifier: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
category?: string;
|
||||
objectName?: string | null;
|
||||
icon?: string | null;
|
||||
applicationId?: string | null;
|
||||
};
|
||||
|
||||
type Application = {
|
||||
id: string;
|
||||
name: string;
|
||||
universalIdentifier: string;
|
||||
logo?: string | null;
|
||||
};
|
||||
|
||||
type MarketplaceApp = {
|
||||
id: string;
|
||||
universalIdentifier: string;
|
||||
icon: string;
|
||||
logo?: string | null;
|
||||
};
|
||||
import { type SettingsAgentToolApplication } from '~/pages/settings/ai/types/SettingsAgentToolApplication';
|
||||
import { type SettingsAgentToolItem } from '~/pages/settings/ai/types/SettingsAgentToolItem';
|
||||
import { type SettingsAgentToolMarketplaceApp } from '~/pages/settings/ai/types/SettingsAgentToolMarketplaceApp';
|
||||
import { getToolApplicationId } from '~/pages/settings/ai/utils/getToolApplicationId';
|
||||
import { getToolLink } from '~/pages/settings/ai/utils/getToolLink';
|
||||
|
||||
type SettingsAgentToolsTableProps = {
|
||||
tools: SettingsAgentToolItem[];
|
||||
isLoading: boolean;
|
||||
applicationById: Map<string, Application>;
|
||||
marketplaceAppByUniversalIdentifier: Map<string, MarketplaceApp>;
|
||||
applicationById: Map<string, SettingsAgentToolApplication>;
|
||||
marketplaceAppByUniversalIdentifier: Map<
|
||||
string,
|
||||
SettingsAgentToolMarketplaceApp
|
||||
>;
|
||||
currentWorkspace: CurrentWorkspace | null;
|
||||
};
|
||||
|
||||
@@ -55,28 +36,6 @@ const StyledTableHeaderRowContainer = styled.div`
|
||||
margin-bottom: ${themeCssVariables.spacing[2]};
|
||||
`;
|
||||
|
||||
const getToolApplicationId = (
|
||||
tool: SettingsAgentToolItem,
|
||||
currentWorkspace: CurrentWorkspace | null,
|
||||
): string => {
|
||||
if (isDefined(tool.applicationId)) {
|
||||
return tool.applicationId;
|
||||
}
|
||||
|
||||
return (
|
||||
currentWorkspace?.installedApplications?.find(
|
||||
(app) =>
|
||||
app.universalIdentifier ===
|
||||
TWENTY_STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER,
|
||||
)?.id ?? ''
|
||||
);
|
||||
};
|
||||
|
||||
const getToolLink = (tool: SettingsAgentToolItem): string =>
|
||||
getSettingsPath(SettingsPath.AiToolDetail, {
|
||||
toolIdentifier: tool.identifier,
|
||||
});
|
||||
|
||||
export const SettingsAgentToolsTable = ({
|
||||
tools,
|
||||
isLoading,
|
||||
|
||||
@@ -10,17 +10,17 @@ import { getModelIcon } from '@/settings/ai/utils/getModelIcon';
|
||||
import { SettingsCard } from '@/settings/components/SettingsCard';
|
||||
import { SettingsOptionCardContentSelect } from '@/settings/components/SettingsOptions/SettingsOptionCardContentSelect';
|
||||
import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { Select } from '@/ui/input/components/Select';
|
||||
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
|
||||
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useMutation, useQuery } from '@apollo/client/react';
|
||||
import { useQuery } from '@apollo/client/react';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import {
|
||||
AUTO_SELECT_FAST_MODEL_ID,
|
||||
AUTO_SELECT_SMART_MODEL_ID,
|
||||
} from 'twenty-shared/constants';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
H2Title,
|
||||
IconBolt,
|
||||
@@ -32,12 +32,8 @@ import { SearchInput } from 'twenty-ui/input';
|
||||
import { Card, Section } from 'twenty-ui/layout';
|
||||
import { UndecoratedLink } from 'twenty-ui/navigation';
|
||||
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
GetAiSystemPromptPreviewDocument,
|
||||
UpdateWorkspaceDocument,
|
||||
} from '~/generated-metadata/graphql';
|
||||
import { GetAiSystemPromptPreviewDocument } from '~/generated-metadata/graphql';
|
||||
import { useSettingsAiModelsActions } from '~/pages/settings/ai/hooks/useSettingsAiModelsActions';
|
||||
import { formatNumber } from '~/utils/format/formatNumber';
|
||||
|
||||
const StyledCustomModelsContainer = styled.div`
|
||||
@@ -49,14 +45,19 @@ const StyledCustomModelsContainer = styled.div`
|
||||
|
||||
export const SettingsAiModelsTab = () => {
|
||||
const { theme } = useContext(ThemeContext);
|
||||
const { enqueueErrorSnackBar } = useSnackBar();
|
||||
const [currentWorkspace, setCurrentWorkspace] = useAtomState(
|
||||
currentWorkspaceState,
|
||||
);
|
||||
const [updateWorkspace] = useMutation(UpdateWorkspaceDocument);
|
||||
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const { data: previewData } = useQuery(GetAiSystemPromptPreviewDocument);
|
||||
const aiModels = useAtomStateValue(aiModelsState);
|
||||
const { useRecommendedModels, realModels, enabledModels } =
|
||||
useWorkspaceAiModelAvailability();
|
||||
const {
|
||||
handleModelFieldChange,
|
||||
handleUseRecommendedToggle,
|
||||
handleModelToggle,
|
||||
handleToggleAllVisibleModels,
|
||||
} = useSettingsAiModelsActions();
|
||||
|
||||
const systemPromptTokenCount =
|
||||
previewData?.getAiSystemPromptPreview.estimatedTokenCount;
|
||||
@@ -67,9 +68,6 @@ export const SettingsAiModelsTab = () => {
|
||||
)} tokens)`
|
||||
: t`Read the system prompts to understand how the AI works`;
|
||||
|
||||
const { useRecommendedModels, realModels, enabledModels } =
|
||||
useWorkspaceAiModelAvailability();
|
||||
|
||||
const currentSmartModel = currentWorkspace?.smartModel;
|
||||
const currentFastModel = currentWorkspace?.fastModel;
|
||||
|
||||
@@ -103,110 +101,11 @@ export const SettingsAiModelsTab = () => {
|
||||
};
|
||||
});
|
||||
|
||||
const handleModelFieldChange = async (
|
||||
field: 'smartModel' | 'fastModel',
|
||||
value: string,
|
||||
) => {
|
||||
if (!currentWorkspace?.id) return;
|
||||
const previousValue = currentWorkspace[field];
|
||||
try {
|
||||
setCurrentWorkspace({ ...currentWorkspace, [field]: value });
|
||||
await updateWorkspace({ variables: { input: { [field]: value } } });
|
||||
} catch {
|
||||
setCurrentWorkspace({ ...currentWorkspace, [field]: previousValue });
|
||||
enqueueErrorSnackBar({ message: t`Failed to update model` });
|
||||
}
|
||||
};
|
||||
|
||||
const enabledModelIdSet = new Set(currentWorkspace?.enabledAiModelIds ?? []);
|
||||
|
||||
const handleUseRecommendedToggle = async (checked: boolean) => {
|
||||
if (!currentWorkspace?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousValue = currentWorkspace.useRecommendedModels;
|
||||
|
||||
let newEnabledIds = currentWorkspace.enabledAiModelIds ?? [];
|
||||
|
||||
if (!checked && previousValue) {
|
||||
const recommendedModelIds = realModels
|
||||
.filter((model) => model.isRecommended)
|
||||
.map((model) => model.modelId);
|
||||
|
||||
newEnabledIds = recommendedModelIds;
|
||||
}
|
||||
|
||||
try {
|
||||
setCurrentWorkspace({
|
||||
...currentWorkspace,
|
||||
useRecommendedModels: checked,
|
||||
enabledAiModelIds: newEnabledIds,
|
||||
});
|
||||
|
||||
await updateWorkspace({
|
||||
variables: {
|
||||
input: {
|
||||
useRecommendedModels: checked,
|
||||
enabledAiModelIds: newEnabledIds,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
setCurrentWorkspace({
|
||||
...currentWorkspace,
|
||||
useRecommendedModels: previousValue,
|
||||
});
|
||||
|
||||
enqueueErrorSnackBar({
|
||||
message: t`Failed to update model selection mode`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleModelToggle = async (
|
||||
modelId: string,
|
||||
isCurrentlyEnabled: boolean,
|
||||
) => {
|
||||
if (!currentWorkspace?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousEnabled = currentWorkspace.enabledAiModelIds ?? [];
|
||||
|
||||
const newEnabledIds = isCurrentlyEnabled
|
||||
? previousEnabled.filter((id) => id !== modelId)
|
||||
: [...previousEnabled, modelId];
|
||||
|
||||
try {
|
||||
setCurrentWorkspace({
|
||||
...currentWorkspace,
|
||||
enabledAiModelIds: newEnabledIds,
|
||||
});
|
||||
|
||||
await updateWorkspace({
|
||||
variables: {
|
||||
input: {
|
||||
enabledAiModelIds: newEnabledIds,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
setCurrentWorkspace({
|
||||
...currentWorkspace,
|
||||
enabledAiModelIds: previousEnabled,
|
||||
});
|
||||
|
||||
enqueueErrorSnackBar({
|
||||
message: t`Failed to update model availability`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const filteredModels = searchQuery.trim()
|
||||
? realModels.filter((model) => {
|
||||
const query = searchQuery.toLowerCase();
|
||||
|
||||
return (
|
||||
model.label.toLowerCase().includes(query) ||
|
||||
(model.modelFamily?.toLowerCase().includes(query) ?? false) ||
|
||||
@@ -284,34 +183,12 @@ export const SettingsAiModelsTab = () => {
|
||||
models={filteredModels}
|
||||
isChecked={(model) => enabledModelIdSet.has(model.modelId)}
|
||||
onToggle={handleModelToggle}
|
||||
onToggleAll={async (shouldCheckAll) => {
|
||||
const previousIds = currentWorkspace?.enabledAiModelIds ?? [];
|
||||
const visibleModelIds = new Set(
|
||||
filteredModels.map((m) => m.modelId),
|
||||
);
|
||||
|
||||
const newEnabledIds = shouldCheckAll
|
||||
? [...new Set([...previousIds, ...visibleModelIds])]
|
||||
: previousIds.filter((id) => !visibleModelIds.has(id));
|
||||
|
||||
try {
|
||||
setCurrentWorkspace({
|
||||
...currentWorkspace!,
|
||||
enabledAiModelIds: newEnabledIds,
|
||||
});
|
||||
await updateWorkspace({
|
||||
variables: { input: { enabledAiModelIds: newEnabledIds } },
|
||||
});
|
||||
} catch {
|
||||
setCurrentWorkspace({
|
||||
...currentWorkspace!,
|
||||
enabledAiModelIds: previousIds,
|
||||
});
|
||||
enqueueErrorSnackBar({
|
||||
message: t`Failed to update model availability`,
|
||||
});
|
||||
}
|
||||
}}
|
||||
onToggleAll={(shouldCheckAll) =>
|
||||
handleToggleAllVisibleModels(
|
||||
shouldCheckAll,
|
||||
new Set(filteredModels.map((m) => m.modelId)),
|
||||
)
|
||||
}
|
||||
anchorPrefix="workspace-model-row"
|
||||
/>
|
||||
</StyledCustomModelsContainer>
|
||||
|
||||
@@ -22,7 +22,6 @@ type ApplicationInfo = {
|
||||
};
|
||||
|
||||
type MarketplaceAppInfo = {
|
||||
icon: string;
|
||||
logo?: string | null;
|
||||
};
|
||||
|
||||
@@ -109,11 +108,6 @@ export const SettingsToolIcon = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (isDefined(marketplaceApp)) {
|
||||
const MarketplaceIcon = getIcon(marketplaceApp.icon);
|
||||
return <MarketplaceIcon size={16} />;
|
||||
}
|
||||
|
||||
if (isDefined(application)) {
|
||||
return (
|
||||
<Avatar
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const FIND_MANY_APPLICATIONS_FOR_TOOL_TABLE = gql`
|
||||
query FindManyApplicationsForToolTable {
|
||||
findManyApplications {
|
||||
id
|
||||
name
|
||||
universalIdentifier
|
||||
logo
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const FIND_MANY_MARKETPLACE_APPS_FOR_TOOL_TABLE = gql`
|
||||
query FindManyMarketplaceAppsForToolTable {
|
||||
findManyMarketplaceApps {
|
||||
id
|
||||
logo
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { usePersistLogicFunction } from '@/logic-functions/hooks/usePersistLogicFunction';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
|
||||
export const useCreateTool = () => {
|
||||
const navigate = useNavigate();
|
||||
const { enqueueSuccessSnackBar } = useSnackBar();
|
||||
const { createLogicFunction } = usePersistLogicFunction();
|
||||
const [isCreatingTool, setIsCreatingTool] = useState(false);
|
||||
|
||||
const handleCreateTool = async () => {
|
||||
setIsCreatingTool(true);
|
||||
try {
|
||||
const result = await createLogicFunction({
|
||||
input: {
|
||||
name: 'new-tool',
|
||||
toolTriggerSettings: {
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Failure path already surfaces its own snackbar from usePersistLogicFunction.
|
||||
if (result.status !== 'successful' || !isDefined(result.response?.data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newLogicFunction = result.response.data.createOneLogicFunction;
|
||||
enqueueSuccessSnackBar({ message: t`Tool created` });
|
||||
|
||||
const applicationId = newLogicFunction.applicationId;
|
||||
if (isDefined(applicationId)) {
|
||||
navigate(
|
||||
getSettingsPath(SettingsPath.ApplicationLogicFunctionDetail, {
|
||||
applicationId,
|
||||
logicFunctionId: newLogicFunction.id,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
navigate(
|
||||
getSettingsPath(SettingsPath.LogicFunctionDetail, {
|
||||
logicFunctionId: newLogicFunction.id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setIsCreatingTool(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { handleCreateTool, isCreatingTool };
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useQuery } from '@apollo/client/react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useGetToolIndex } from '@/ai/hooks/useGetToolIndex';
|
||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { logicFunctionsSelector } from '@/logic-functions/states/logicFunctionsSelector';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { ToolCategory } from 'twenty-shared/ai';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { FIND_MANY_APPLICATIONS_FOR_TOOL_TABLE } from '~/pages/settings/ai/graphql/queries/findManyApplicationsForToolTable';
|
||||
import { FIND_MANY_MARKETPLACE_APPS_FOR_TOOL_TABLE } from '~/pages/settings/ai/graphql/queries/findManyMarketplaceAppsForToolTable';
|
||||
import { type SettingsAgentToolApplication } from '~/pages/settings/ai/types/SettingsAgentToolApplication';
|
||||
import { type SettingsAgentToolItem } from '~/pages/settings/ai/types/SettingsAgentToolItem';
|
||||
import { type SettingsAgentToolMarketplaceApp } from '~/pages/settings/ai/types/SettingsAgentToolMarketplaceApp';
|
||||
|
||||
export const useSettingsAgentToolsTable = () => {
|
||||
const logicFunctions = useAtomStateValue(logicFunctionsSelector);
|
||||
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
|
||||
const {
|
||||
toolIndex,
|
||||
loading: toolIndexLoading,
|
||||
error: toolIndexError,
|
||||
} = useGetToolIndex();
|
||||
|
||||
const { data: applicationsData } = useQuery<{
|
||||
findManyApplications: SettingsAgentToolApplication[];
|
||||
}>(FIND_MANY_APPLICATIONS_FOR_TOOL_TABLE);
|
||||
|
||||
const { data: marketplaceAppsData } = useQuery<{
|
||||
findManyMarketplaceApps: SettingsAgentToolMarketplaceApp[];
|
||||
}>(FIND_MANY_MARKETPLACE_APPS_FOR_TOOL_TABLE);
|
||||
|
||||
const allTools: SettingsAgentToolItem[] = useMemo(
|
||||
() => [
|
||||
...logicFunctions
|
||||
.filter((fn) => isDefined(fn.toolTriggerSettings))
|
||||
.map((fn) => ({
|
||||
identifier: fn.id,
|
||||
name: fn.name,
|
||||
description: fn.description,
|
||||
applicationId: fn.applicationId,
|
||||
})),
|
||||
...toolIndex
|
||||
.filter((tool) => tool.category !== ToolCategory.LOGIC_FUNCTION)
|
||||
.map((tool) => ({
|
||||
identifier: tool.name,
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
category: tool.category,
|
||||
objectName: tool.objectName,
|
||||
icon: tool.icon,
|
||||
})),
|
||||
],
|
||||
[logicFunctions, toolIndex],
|
||||
);
|
||||
|
||||
const applicationById = new Map(
|
||||
(applicationsData?.findManyApplications ?? []).map((application) => [
|
||||
application.id,
|
||||
application,
|
||||
]),
|
||||
);
|
||||
|
||||
// MarketplaceApp.id IS the universal identifier — see
|
||||
// marketplace-query.service.ts where `id: registration.universalIdentifier`.
|
||||
const marketplaceAppByUniversalIdentifier = new Map(
|
||||
(marketplaceAppsData?.findManyMarketplaceApps ?? []).map(
|
||||
(marketplaceApp) => [marketplaceApp.id, marketplaceApp],
|
||||
),
|
||||
);
|
||||
|
||||
const isLoading = toolIndexLoading && !toolIndexError;
|
||||
|
||||
return {
|
||||
allTools,
|
||||
applicationById,
|
||||
marketplaceAppByUniversalIdentifier,
|
||||
currentWorkspace,
|
||||
isLoading,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useMutation } from '@apollo/client/react';
|
||||
import { t } from '@lingui/core/macro';
|
||||
|
||||
import { useWorkspaceAiModelAvailability } from '@/ai/hooks/useWorkspaceAiModelAvailability';
|
||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
|
||||
import { UpdateWorkspaceDocument } from '~/generated-metadata/graphql';
|
||||
|
||||
export const useSettingsAiModelsActions = () => {
|
||||
const { enqueueErrorSnackBar } = useSnackBar();
|
||||
const [currentWorkspace, setCurrentWorkspace] = useAtomState(
|
||||
currentWorkspaceState,
|
||||
);
|
||||
const [updateWorkspace] = useMutation(UpdateWorkspaceDocument);
|
||||
const { realModels } = useWorkspaceAiModelAvailability();
|
||||
|
||||
const handleModelFieldChange = async (
|
||||
field: 'smartModel' | 'fastModel',
|
||||
value: string,
|
||||
) => {
|
||||
if (!currentWorkspace?.id) return;
|
||||
|
||||
const previousValue = currentWorkspace[field];
|
||||
try {
|
||||
setCurrentWorkspace({ ...currentWorkspace, [field]: value });
|
||||
await updateWorkspace({ variables: { input: { [field]: value } } });
|
||||
} catch {
|
||||
setCurrentWorkspace({ ...currentWorkspace, [field]: previousValue });
|
||||
enqueueErrorSnackBar({ message: t`Failed to update model` });
|
||||
}
|
||||
};
|
||||
|
||||
const handleUseRecommendedToggle = async (checked: boolean) => {
|
||||
if (!currentWorkspace?.id) return;
|
||||
|
||||
const previousValue = currentWorkspace.useRecommendedModels;
|
||||
let newEnabledIds = currentWorkspace.enabledAiModelIds ?? [];
|
||||
|
||||
if (!checked && previousValue) {
|
||||
newEnabledIds = realModels
|
||||
.filter((model) => model.isRecommended)
|
||||
.map((model) => model.modelId);
|
||||
}
|
||||
|
||||
try {
|
||||
setCurrentWorkspace({
|
||||
...currentWorkspace,
|
||||
useRecommendedModels: checked,
|
||||
enabledAiModelIds: newEnabledIds,
|
||||
});
|
||||
await updateWorkspace({
|
||||
variables: {
|
||||
input: {
|
||||
useRecommendedModels: checked,
|
||||
enabledAiModelIds: newEnabledIds,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
setCurrentWorkspace({
|
||||
...currentWorkspace,
|
||||
useRecommendedModels: previousValue,
|
||||
});
|
||||
enqueueErrorSnackBar({
|
||||
message: t`Failed to update model selection mode`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleModelToggle = async (
|
||||
modelId: string,
|
||||
isCurrentlyEnabled: boolean,
|
||||
) => {
|
||||
if (!currentWorkspace?.id) return;
|
||||
|
||||
const previousEnabled = currentWorkspace.enabledAiModelIds ?? [];
|
||||
const newEnabledIds = isCurrentlyEnabled
|
||||
? previousEnabled.filter((id) => id !== modelId)
|
||||
: [...previousEnabled, modelId];
|
||||
|
||||
try {
|
||||
setCurrentWorkspace({
|
||||
...currentWorkspace,
|
||||
enabledAiModelIds: newEnabledIds,
|
||||
});
|
||||
await updateWorkspace({
|
||||
variables: { input: { enabledAiModelIds: newEnabledIds } },
|
||||
});
|
||||
} catch {
|
||||
setCurrentWorkspace({
|
||||
...currentWorkspace,
|
||||
enabledAiModelIds: previousEnabled,
|
||||
});
|
||||
enqueueErrorSnackBar({
|
||||
message: t`Failed to update model availability`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleAllVisibleModels = async (
|
||||
shouldCheckAll: boolean,
|
||||
visibleModelIds: Set<string>,
|
||||
) => {
|
||||
if (!currentWorkspace?.id) return;
|
||||
|
||||
const previousIds = currentWorkspace.enabledAiModelIds ?? [];
|
||||
const newEnabledIds = shouldCheckAll
|
||||
? [...new Set([...previousIds, ...visibleModelIds])]
|
||||
: previousIds.filter((id) => !visibleModelIds.has(id));
|
||||
|
||||
try {
|
||||
setCurrentWorkspace({
|
||||
...currentWorkspace,
|
||||
enabledAiModelIds: newEnabledIds,
|
||||
});
|
||||
await updateWorkspace({
|
||||
variables: { input: { enabledAiModelIds: newEnabledIds } },
|
||||
});
|
||||
} catch {
|
||||
setCurrentWorkspace({
|
||||
...currentWorkspace,
|
||||
enabledAiModelIds: previousIds,
|
||||
});
|
||||
enqueueErrorSnackBar({
|
||||
message: t`Failed to update model availability`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
handleModelFieldChange,
|
||||
handleUseRecommendedToggle,
|
||||
handleModelToggle,
|
||||
handleToggleAllVisibleModels,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export type SettingsAgentToolApplication = {
|
||||
id: string;
|
||||
name: string;
|
||||
universalIdentifier: string;
|
||||
logo?: string | null;
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
export type SettingsAgentToolItem = {
|
||||
identifier: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
category?: string;
|
||||
objectName?: string | null;
|
||||
icon?: string | null;
|
||||
applicationId?: string | null;
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export type SettingsAgentToolMarketplaceApp = {
|
||||
id: string;
|
||||
logo?: string | null;
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { type CurrentWorkspace } from '@/auth/states/currentWorkspaceState';
|
||||
import { type SettingsAgentToolItem } from '~/pages/settings/ai/types/SettingsAgentToolItem';
|
||||
import { TWENTY_STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER } from 'twenty-shared/application';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const getToolApplicationId = (
|
||||
tool: SettingsAgentToolItem,
|
||||
currentWorkspace: CurrentWorkspace | null,
|
||||
): string => {
|
||||
if (isDefined(tool.applicationId)) {
|
||||
return tool.applicationId;
|
||||
}
|
||||
|
||||
return (
|
||||
currentWorkspace?.installedApplications?.find(
|
||||
(app) =>
|
||||
app.universalIdentifier ===
|
||||
TWENTY_STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER,
|
||||
)?.id ?? ''
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import { type SettingsAgentToolItem } from '~/pages/settings/ai/types/SettingsAgentToolItem';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import { getSettingsPath } from 'twenty-shared/utils';
|
||||
|
||||
export const getToolLink = (tool: SettingsAgentToolItem): string =>
|
||||
getSettingsPath(SettingsPath.AiToolDetail, {
|
||||
toolIdentifier: tool.identifier,
|
||||
});
|
||||
@@ -11,9 +11,8 @@ import { SettingsPath } from 'twenty-shared/types';
|
||||
import { getSettingsPath } from 'twenty-shared/utils';
|
||||
import { IconApps, IconCode, IconDownload, IconPlug } from 'twenty-ui/display';
|
||||
import { Section } from 'twenty-ui/layout';
|
||||
// TODO: replace with apps-specific illustrations + recordings when designed.
|
||||
import placeholderHeroDark from '~/pages/settings/layout/assets/customize-illustration-dark.png';
|
||||
import placeholderHeroLight from '~/pages/settings/layout/assets/customize-illustration-light.png';
|
||||
import coverDark from '~/pages/settings/applications/assets/cover-dark.png';
|
||||
import coverLight from '~/pages/settings/applications/assets/cover-light.png';
|
||||
import {
|
||||
FeatureFlagKey,
|
||||
PermissionFlagType,
|
||||
@@ -82,8 +81,8 @@ export const SettingsApplications = () => {
|
||||
<SettingsPageContainer>
|
||||
<Section>
|
||||
<SettingsDiscoveryHeroCard
|
||||
lightSrc={placeholderHeroLight}
|
||||
darkSrc={placeholderHeroDark}
|
||||
lightSrc={coverLight}
|
||||
darkSrc={coverDark}
|
||||
instanceIdPrefix={APPLICATIONS_HERO_INSTANCE_ID_PREFIX}
|
||||
tabs={[
|
||||
{
|
||||
|
||||
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 90 KiB |
@@ -5,7 +5,6 @@ import { SettingsPageContainer } from '@/settings/components/SettingsPageContain
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useMemo } from 'react';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import { getSettingsPath } from 'twenty-shared/utils';
|
||||
import {
|
||||
@@ -31,29 +30,26 @@ export const SettingsObjects = () => {
|
||||
const { objectMetadataItems } = useFilteredObjectMetadataItems();
|
||||
const isDDLLocked = useAtomStateValue(isDDLLockedState);
|
||||
|
||||
const heroTabs = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: 'objects',
|
||||
title: t`Objects`,
|
||||
Icon: IconHierarchy2,
|
||||
vimeoId: '926288174',
|
||||
},
|
||||
{
|
||||
id: 'fields',
|
||||
title: t`Fields`,
|
||||
Icon: IconList,
|
||||
vimeoId: '927628219',
|
||||
},
|
||||
{
|
||||
id: 'relations',
|
||||
title: t`Relations`,
|
||||
Icon: IconLink,
|
||||
vimeoId: '1185511827',
|
||||
},
|
||||
],
|
||||
[t],
|
||||
);
|
||||
const heroTabs = [
|
||||
{
|
||||
id: 'objects',
|
||||
title: t`Objects`,
|
||||
Icon: IconHierarchy2,
|
||||
vimeoId: '926288174',
|
||||
},
|
||||
{
|
||||
id: 'fields',
|
||||
title: t`Fields`,
|
||||
Icon: IconList,
|
||||
vimeoId: '927628219',
|
||||
},
|
||||
{
|
||||
id: 'relations',
|
||||
title: t`Relations`,
|
||||
Icon: IconLink,
|
||||
vimeoId: '1185511827',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<SubMenuTopBarContainer
|
||||
|
||||
@@ -4,7 +4,6 @@ import { SettingsPageContainer } from '@/settings/components/SettingsPageContain
|
||||
import { SettingsLayoutItemsStats } from '@/settings/layout/components/SettingsLayoutItemsStats';
|
||||
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { AppPath, SettingsPath } from 'twenty-shared/types';
|
||||
import { getSettingsPath } from 'twenty-shared/utils';
|
||||
@@ -19,8 +18,8 @@ import {
|
||||
} from 'twenty-ui/display';
|
||||
import { Button } from 'twenty-ui/input';
|
||||
import { Section } from 'twenty-ui/layout';
|
||||
import customizeIllustrationDark from '~/pages/settings/layout/assets/customize-illustration-dark.png';
|
||||
import customizeIllustrationLight from '~/pages/settings/layout/assets/customize-illustration-light.png';
|
||||
import coverDark from '~/pages/settings/layout/assets/cover-dark.png';
|
||||
import coverLight from '~/pages/settings/layout/assets/cover-light.png';
|
||||
|
||||
const SETTINGS_LAYOUT_HERO_INSTANCE_ID_PREFIX = 'settings-layout-hero';
|
||||
|
||||
@@ -29,52 +28,44 @@ export const SettingsLayout = () => {
|
||||
const navigate = useNavigate();
|
||||
const { enterLayoutCustomizationMode } = useEnterLayoutCustomizationMode();
|
||||
|
||||
// Match the in-app customization entry points: arm customization mode before
|
||||
// landing in the app, otherwise the user just drops into the app normally.
|
||||
// Skip navigation when entry was blocked (e.g. a dashboard is mid-edit) so we
|
||||
// don't yank the user away after the warning.
|
||||
const handleCustomize = () => {
|
||||
if (enterLayoutCustomizationMode()) {
|
||||
navigate(AppPath.Index);
|
||||
}
|
||||
};
|
||||
|
||||
// TODO: replace placeholder demo videos per tab
|
||||
const heroTabs = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: 'sidebar',
|
||||
title: t`Sidebar`,
|
||||
Icon: IconLayoutSidebarLeftExpand,
|
||||
vimeoId: '1185511790',
|
||||
},
|
||||
{
|
||||
id: 'record-page',
|
||||
title: t`Record page`,
|
||||
Icon: IconAppWindow,
|
||||
vimeoId: '1185511790',
|
||||
},
|
||||
{
|
||||
id: 'command-menu',
|
||||
title: t`Command menu`,
|
||||
Icon: IconCommand,
|
||||
vimeoId: '1185416775',
|
||||
},
|
||||
{
|
||||
id: 'views',
|
||||
title: t`Views`,
|
||||
Icon: IconTable,
|
||||
vimeoId: '1145648745',
|
||||
},
|
||||
{
|
||||
id: 'dashboards',
|
||||
title: t`Dashboards`,
|
||||
Icon: IconLayoutDashboard,
|
||||
vimeoId: '1185511768',
|
||||
},
|
||||
],
|
||||
[t],
|
||||
);
|
||||
const heroTabs = [
|
||||
{
|
||||
id: 'sidebar',
|
||||
title: t`Sidebar`,
|
||||
Icon: IconLayoutSidebarLeftExpand,
|
||||
vimeoId: '1185511790',
|
||||
},
|
||||
{
|
||||
id: 'record-page',
|
||||
title: t`Record page`,
|
||||
Icon: IconAppWindow,
|
||||
vimeoId: '1185511790',
|
||||
},
|
||||
{
|
||||
id: 'command-menu',
|
||||
title: t`Command menu`,
|
||||
Icon: IconCommand,
|
||||
vimeoId: '1185416775',
|
||||
},
|
||||
{
|
||||
id: 'views',
|
||||
title: t`Views`,
|
||||
Icon: IconTable,
|
||||
vimeoId: '1145648745',
|
||||
},
|
||||
{
|
||||
id: 'dashboards',
|
||||
title: t`Dashboards`,
|
||||
Icon: IconLayoutDashboard,
|
||||
vimeoId: '1185511768',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<SubMenuTopBarContainer
|
||||
@@ -100,8 +91,8 @@ export const SettingsLayout = () => {
|
||||
<SettingsPageContainer>
|
||||
<Section>
|
||||
<SettingsDiscoveryHeroCard
|
||||
lightSrc={customizeIllustrationLight}
|
||||
darkSrc={customizeIllustrationDark}
|
||||
lightSrc={coverLight}
|
||||
darkSrc={coverDark}
|
||||
instanceIdPrefix={SETTINGS_LAYOUT_HERO_INSTANCE_ID_PREFIX}
|
||||
tabs={heroTabs}
|
||||
playButtonAriaLabel={t`Watch customization demo`}
|
||||
|
||||
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 206 KiB |
|
Before Width: | Height: | Size: 208 KiB |
@@ -15,9 +15,8 @@ import { PermissionFlagType } from '~/generated-metadata/graphql';
|
||||
import { SettingsWorkspaceMembersInviteTab } from '~/pages/settings/members/tabs/SettingsWorkspaceMembersInviteTab';
|
||||
import { SettingsWorkspaceMembersRolesTab } from '~/pages/settings/members/tabs/SettingsWorkspaceMembersRolesTab';
|
||||
import { SettingsWorkspaceMembersTeamTab } from '~/pages/settings/members/tabs/SettingsWorkspaceMembersTeamTab';
|
||||
// TODO: replace with members-specific illustrations + recordings when designed.
|
||||
import placeholderHeroDark from '~/pages/settings/layout/assets/customize-illustration-dark.png';
|
||||
import placeholderHeroLight from '~/pages/settings/layout/assets/customize-illustration-light.png';
|
||||
import coverDark from '~/pages/settings/members/assets/cover-dark.png';
|
||||
import coverLight from '~/pages/settings/members/assets/cover-light.png';
|
||||
|
||||
const MEMBERS_TAB_LIST_ID = 'members-tab-list';
|
||||
|
||||
@@ -74,8 +73,8 @@ export const SettingsWorkspaceMembers = () => {
|
||||
<SettingsPageContainer>
|
||||
<Section>
|
||||
<SettingsDiscoveryHeroCard
|
||||
lightSrc={placeholderHeroLight}
|
||||
darkSrc={placeholderHeroDark}
|
||||
lightSrc={coverLight}
|
||||
darkSrc={coverDark}
|
||||
instanceIdPrefix={SETTINGS_MEMBERS_HERO_INSTANCE_ID_PREFIX}
|
||||
tabs={[
|
||||
{
|
||||
|
||||
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 94 KiB |
@@ -62,9 +62,8 @@ export class ApiKeyResolver {
|
||||
}
|
||||
}
|
||||
|
||||
// A long-lived API key is durable credential management: only a first-person
|
||||
// session (ACCESS) may mint or alter one, so a short-lived derived token
|
||||
// (PLAYGROUND) or an API key itself cannot escalate into another.
|
||||
// Minting an API key requires an ACCESS token — derived PLAYGROUND tokens
|
||||
// and API keys must not escalate into a long-lived credential.
|
||||
@UseGuards(RequireAccessTokenGuard)
|
||||
@Mutation(() => ApiKeyEntity)
|
||||
async createApiKey(
|
||||
|
||||
@@ -55,8 +55,8 @@ export class ApiKeyController {
|
||||
return this.apiKeyService.findById(id, workspace.id);
|
||||
}
|
||||
|
||||
// Minting/altering a long-lived API key requires a first-person session
|
||||
// (ACCESS); a derived PLAYGROUND token or an API key cannot escalate here.
|
||||
// Minting an API key requires an ACCESS token — derived PLAYGROUND tokens
|
||||
// and API keys must not escalate into a long-lived credential.
|
||||
@UseGuards(RequireAccessTokenGuard)
|
||||
@Post()
|
||||
async create(
|
||||
|
||||
@@ -944,11 +944,7 @@ describe('JwtAuthStrategy', () => {
|
||||
});
|
||||
|
||||
describe('PLAYGROUND token validation', () => {
|
||||
// A PLAYGROUND token is access-shaped but must never impersonate. This is the
|
||||
// same payload that, as an ACCESS token with isImpersonating set but without
|
||||
// impersonation ids, throws 'Invalid or missing user workspace ID in
|
||||
// impersonation token' above; as a PLAYGROUND token it must skip the
|
||||
// impersonation path entirely and resolve to the first-person context.
|
||||
// PLAYGROUND tokens are access-shaped but must never impersonate.
|
||||
it('ignores isImpersonating and resolves first-person', async () => {
|
||||
const validUserId = 'valid-user-id';
|
||||
const validUserWorkspaceId = randomUUID();
|
||||
|
||||
@@ -7,9 +7,8 @@ export type AppOAuthStateJwtPayload = CommonPropertiesJwtPayload & {
|
||||
connectionProviderId: string;
|
||||
userId: string;
|
||||
userWorkspaceId: string;
|
||||
// 'user' = the resulting credential is private to userWorkspaceId.
|
||||
// 'workspace' = visible to anyone in the workspace.
|
||||
// Named `visibility` to disambiguate from OAuth `scopes` on the row.
|
||||
// 'user' scopes the credential to userWorkspaceId; 'workspace' makes it
|
||||
// visible to anyone in the workspace.
|
||||
visibility: 'user' | 'workspace';
|
||||
// If set, the callback updates this existing connectedAccount row instead
|
||||
// of creating a new one (used by the UI's "Reconnect" action).
|
||||
|
||||
@@ -20,6 +20,14 @@ export const PUBLIC_FEATURE_FLAGS: PublicFeatureFlag[] = [
|
||||
'Enable many-to-many relations through junction tables configuration',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: FeatureFlagKey.IS_SETTINGS_DISCOVERY_HERO_ENABLED,
|
||||
metadata: {
|
||||
label: 'Settings Discovery Hero',
|
||||
description:
|
||||
'Show the per-page hero illustration + video walkthrough modal on settings pages',
|
||||
},
|
||||
},
|
||||
...(process.env.CLOUDFLARE_API_KEY
|
||||
? [
|
||||
// {
|
||||
|
||||
@@ -237,6 +237,7 @@ describe('WorkspaceEntityManager', () => {
|
||||
IS_EMAIL_GROUP_ENABLED: false,
|
||||
IS_JUNCTION_RELATIONS_ENABLED: false,
|
||||
IS_REST_METADATA_API_NEW_FORMAT_DIRECT: false,
|
||||
IS_SETTINGS_DISCOVERY_HERO_ENABLED: false,
|
||||
},
|
||||
userWorkspaceRoleMap: {},
|
||||
eventEmitterService: {
|
||||
|
||||
@@ -45,6 +45,11 @@ export const seedFeatureFlags = async ({
|
||||
workspaceId: workspaceId,
|
||||
value: true,
|
||||
},
|
||||
{
|
||||
key: FeatureFlagKey.IS_SETTINGS_DISCOVERY_HERO_ENABLED,
|
||||
workspaceId: workspaceId,
|
||||
value: false,
|
||||
},
|
||||
])
|
||||
.execute();
|
||||
};
|
||||
|
||||
@@ -33,9 +33,8 @@ describe('api key and webhooks permissions', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// A non-session token (here API_KEY; PLAYGROUND shares the same access path)
|
||||
// must not mint a durable API key, otherwise a short-lived token could
|
||||
// escalate into a long-lived one. RequireAccessTokenGuard enforces this.
|
||||
// Non-ACCESS tokens (API_KEY here, PLAYGROUND same path) must never mint
|
||||
// an API key — enforced by RequireAccessTokenGuard.
|
||||
it('should reject a non-ACCESS token even with API key permission', async () => {
|
||||
const queryData = {
|
||||
query: `
|
||||
|
||||
@@ -6,4 +6,5 @@ export enum FeatureFlagKey {
|
||||
IS_EMAIL_GROUP_ENABLED = 'IS_EMAIL_GROUP_ENABLED',
|
||||
IS_JUNCTION_RELATIONS_ENABLED = 'IS_JUNCTION_RELATIONS_ENABLED',
|
||||
IS_REST_METADATA_API_NEW_FORMAT_DIRECT = 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT',
|
||||
IS_SETTINGS_DISCOVERY_HERO_ENABLED = 'IS_SETTINGS_DISCOVERY_HERO_ENABLED',
|
||||
}
|
||||
|
||||