From 70ddd1c229a73e663c5227f7b97506b945866c7a Mon Sep 17 00:00:00 2001 From: Oleksandr Koltunov Date: Thu, 16 Jul 2026 10:42:56 +0300 Subject: [PATCH 1/5] fix(): notifications history mobile view --- schema.graphql | 148 +++++++++++++++++- .../components/notifications-columns.tsx | 35 +++-- 2 files changed, 168 insertions(+), 15 deletions(-) diff --git a/schema.graphql b/schema.graphql index 5307f49..987b30d 100644 --- a/schema.graphql +++ b/schema.graphql @@ -1,6 +1,6 @@ # Auto-generated by scripts/fetch-schema.mjs via introspection -# Source: https://test-dev.openframe.build/api/graphql -# Generated: 2026-07-09T19:23:04.279Z +# Source: https://test-frontend.openframe.build/api/graphql +# Generated: 2026-07-15T10:27:33.577Z # # Do not edit manually. Re-run: npm run fetch-schema directive @extends on OBJECT | INTERFACE @@ -57,6 +57,7 @@ type AdminApprovalRequestContext implements NotificationContext { approvalRequestId: ID! dialogId: ID! ticketId: ID + ticketNumber: Int approvalType: ApprovalType! toolCalls: [ApprovalToolCall!]! resolution: ApprovalResolution @@ -284,6 +285,15 @@ input CreateScriptInput { tagIds: [ID!] } +input CreateScriptScheduleInput { + name: String! + description: String + supportedPlatforms: [ScriptPlatform!] + + """Ids of existing Scripts to run, in run order.""" + scriptIds: [ID!] +} + input CreateTimeEntryInput { userId: ID! ticketId: ID @@ -1048,6 +1058,13 @@ type Mutation { """Update the current subscription (change packages, toggle PAYG).""" updateSubscription(input: UpdateSubscriptionInput!): UpdateSubscriptionResult! + """ + Cancel a pending package downgrade, reverting to the current (higher) tier. Drops the deferred + PENDING_ACTIVATION option and its queued Stripe schedule phase so the scheduled reduction never takes + effect. Errors if there is no pending downgrade. + """ + cancelPendingDowngrade: UpdateSubscriptionResult! + """Detach all payment methods. Returns the count of detached methods.""" detachPaymentMethods: Int! @@ -1076,6 +1093,37 @@ type Mutation { markAllNotificationsAsRead: Int! deleteNotification(notificationId: ID!): Boolean! deleteAllReadNotifications: Int! + + """ + Create a new schedule. Returns the created schedule with its server-assigned id. + """ + createScriptSchedule(input: CreateScriptScheduleInput!): ScriptSchedule! + + """ + Full replacement (PUT semantics) of an existing schedule: every writable field on the input overwrites the stored value, including nulls which clear optional fields. The target schedule id travels inside the input. + """ + updateScriptSchedule(input: UpdateScriptScheduleInput!): ScriptSchedule! + + """ + Soft-delete a schedule (sets status to DELETED). Returns the id of the deleted schedule; idempotent on already-deleted schedules. Throws if the id is not found in the tenant. + """ + deleteScriptSchedule(id: ID!): ID! + + """ + Archive a schedule (sets status to ARCHIVED). Idempotent. Returns the updated schedule. Throws if not found or soft-deleted. + """ + archiveScriptSchedule(id: ID!): ScriptSchedule! + + """ + Restore an archived schedule back to ACTIVE. Idempotent. Returns the updated schedule. Throws if not found or soft-deleted. + """ + unarchiveScriptSchedule(id: ID!): ScriptSchedule! + + """ + Replace the full set of devices assigned to a schedule (PUT semantics — backs "Edit Devices"). + machineIds are Machine global ids. Returns the updated schedule. Throws if the schedule is not found or soft-deleted. + """ + setScriptScheduleDevices(scheduleId: ID!, machineIds: [ID!]!): ScriptSchedule! createFolder(name: String!, parentId: ID): KnowledgeBaseItem! renameFolder(id: ID!, name: String!): KnowledgeBaseItem! createArticle(input: CreateArticleInput!): KnowledgeBaseItem! @@ -1468,6 +1516,20 @@ type Query { notifications(filter: NotificationFilterInput, search: String, first: Int, after: String, last: Int, before: String, sort: SortInput): NotificationConnection! hasUnreadNotifications: Boolean! unreadCountsByCategory: [UnreadCategoryCount!]! + + """ + Get a single schedule by id within the current tenant. Throws (not null) if the id is absent, soft-deleted, or in another tenant. + """ + scriptSchedule(id: ID!): ScriptSchedule! + + """ + Cursor-paginated list of schedules within the current tenant (Relay Connection Spec). + Default order is newest-first by _id. Optional filter / search / sort. + Sortable fields: _id (default), name, createdAt, updatedAt. Search is a + case-insensitive substring match on name. + """ + scriptSchedules(filter: ScriptScheduleFilterInput, search: String, sort: SortInput, first: Int, after: String, last: Int, before: String): ScriptScheduleConnection! + scriptScheduleFilters(filter: ScriptScheduleFilterInput): ScriptScheduleFilters! knowledgeBaseItems(filter: KnowledgeBaseFilterInput, search: String, first: Int, after: String): KnowledgeBaseItemConnection! knowledgeBaseItem(id: ID!): KnowledgeBaseItem knowledgeBaseTags(folderId: ID, archived: Boolean): [Tag!]! @@ -1544,7 +1606,7 @@ type Query { """ YYYY-MM-DD format""" toolType: String! - """ Tool type (meshcentral, tactical-rmm, etc.)""" + """ Tool type (meshcentral, fleet-mdm, etc.)""" eventType: String! """ Event type (login, logout, script, etc.)""" @@ -1739,6 +1801,71 @@ enum ScriptPlatform { MACOS } +type ScriptSchedule implements Node { + id: ID! + name: String! + description: String + supportedPlatforms: [ScriptPlatform!] + + """ + The scripts this schedule runs, in run order (resolved from the stored script ids). + """ + scripts: [Script!]! + + """ + Machines assigned to this schedule (resolved from the assignment collection via the machine loader). + """ + assignedDevices: [Machine!]! + + """Number of machines assigned to this schedule (the DEVICES column).""" + deviceCount: Int! + + """ + Lifecycle status: ACTIVE | ARCHIVED | DELETED. DELETED is a soft-delete and is hidden from default queries. + """ + status: ScriptStatus! + statusChangedAt: Instant + createdAt: Instant + updatedAt: Instant + + """ + The creating user (resolved from the internal createdBy id via the user DataLoader). + """ + author: User +} + +type ScriptScheduleConnection { + edges: [ScriptScheduleEdge!]! + pageInfo: PageInfo! + + """ + Total number of schedules matching the current filter/search for the tenant, independent of pagination. + """ + filteredCount: Int! +} + +type ScriptScheduleEdge { + node: ScriptSchedule! + cursor: String! +} + +""" +Filter for the scriptSchedules(...) query. All fields optional — null/empty means +no constraint. The statuses field hides DELETED when null/empty; when supplied it +is used verbatim. +""" +input ScriptScheduleFilterInput { + statuses: [ScriptStatus!] + supportedPlatforms: [ScriptPlatform!] + authorIds: [ID!] +} + +type ScriptScheduleFilters { + platforms: [ScriptFilterOption!]! + authors: [ScriptFilterOption!]! + filteredCount: Int! +} + enum ScriptShell { POWERSHELL CMD @@ -2025,6 +2152,8 @@ type TicketAssignedContext implements NotificationContext { ticketNumber: Int assigneeUserId: ID! assignedByUserId: ID + assigneeName: String + assignedByName: String } type TicketStatusChangedContext implements NotificationContext { @@ -2128,7 +2257,6 @@ type ToolList { enum ToolType { MESHCENTRAL - TACTICAL_RMM FLEET_MDM OPENFRAME_RMM } @@ -2197,6 +2325,18 @@ input UpdateScriptInput { tagIds: [ID!] } +""" +Full-replacement (PUT) payload for an existing schedule. Optional fields accept +null to clear the stored value; name cannot be null. +""" +input UpdateScriptScheduleInput { + id: ID! + name: String! + description: String + supportedPlatforms: [ScriptPlatform!] + scriptIds: [ID!] +} + input UpdateSubscriptionInput { packageUpdates: [PackageUpdateInput!] discountCode: String diff --git a/src/app/(app)/notifications/components/notifications-columns.tsx b/src/app/(app)/notifications/components/notifications-columns.tsx index 3bb41e3..7d2228f 100644 --- a/src/app/(app)/notifications/components/notifications-columns.tsx +++ b/src/app/(app)/notifications/components/notifications-columns.tsx @@ -54,6 +54,8 @@ export function buildNotificationColumns({ meta: { width: 'flex-[2] min-w-0' }, cell: ({ row }: { row: Row }) => { const { category, type, severity, variant = 'default' } = row.original.notification; + const titleColor = (severity && titleColorBySeverity[severity]) ?? 'text-ods-text-primary'; + const relativeTime = formatTicketRelativeTime(new Date(row.original.createdAt).toISOString()); return (
@@ -61,18 +63,18 @@ export function buildNotificationColumns({ )}
-
- +
+ {/* Context-derived kind label; title stands in when the context is generic. */} {type ?? row.original.title} + {relativeTime} +
+ {/* Mobile: the details column is hidden, so title + description collapse into this cell. */} +
+ {row.original.title} - {formatTicketRelativeTime(new Date(row.original.createdAt).toISOString())} + {row.original.description || relativeTime}
@@ -84,7 +86,7 @@ export function buildNotificationColumns({ accessorKey: 'description', header: '', enableSorting: false, - meta: { width: 'flex-[3] min-w-0' }, + meta: { width: 'flex-[3] min-w-0', hideAt: 'md' }, cell: ({ row }: { row: Row }) => { // The title moves here only when the kind label owns the first column; // otherwise it's already shown there and only the description remains. @@ -105,7 +107,7 @@ export function buildNotificationColumns({ id: 'action', header: '', enableSorting: false, - meta: { width: 'w-[210px]', align: 'right' }, + meta: { width: 'w-11 shrink-0 md:w-[210px]', align: 'right' }, cell: ({ row }: { row: Row }) => { const action = resolveNotificationAction(row.original.notification); if (!action) return null; @@ -125,6 +127,7 @@ export function buildNotificationColumns({ return (
{action.label} + {/* Mobile: the labeled SplitButton doesn't fit — collapse to an icon-only open button. */} +
); }, @@ -147,7 +160,7 @@ export function buildNotificationColumns({ id: 'rowIcon', header: '', enableSorting: false, - meta: { width: 'w-12 shrink-0', align: 'right' }, + meta: { width: 'w-11 shrink-0 md:w-12', align: 'right' }, cell: ({ row }: { row: Row }) => (
{rowVariant === 'unread' ? ( From 5b426ef3c2d5cefc15acb54a88b8324e2ece05df Mon Sep 17 00:00:00 2001 From: Oleksandr Koltunov Date: Fri, 17 Jul 2026 14:42:09 +0300 Subject: [PATCH 2/5] fix(): refactor guardrails components --- .../components/customer-details-view.tsx | 8 +- .../details-tabs/customer-guardrails-tab.tsx | 45 ++ .../components/details-tabs/customer-tabs.tsx | 37 +- .../components/ai-settings-view.tsx | 16 +- .../ai-settings/components/guardrails-tab.tsx | 615 ------------------ .../guardrails/build-policy-groups.ts | 56 ++ .../guardrails/guardrails-panel.tsx | 107 +++ .../components/guardrails/guardrails-tab.tsx | 33 + .../guardrails/guardrails-template-picker.tsx | 53 ++ .../guardrails/guardrails.types.ts} | 13 +- .../guardrails/use-guardrails-editor.ts | 248 +++++++ .../guardrails/use-guardrails-policies.ts | 136 ++++ .../(app)/settings/hooks/.use-ai-policies.md | 81 --- .../(app)/settings/hooks/use-ai-policies.ts | 203 ------ src/app/(app)/settings/types/.ai-policies.md | 45 -- src/app/(app)/settings/types/ai-settings.ts | 17 - .../settings/utils/ai-settings.utils.tsx | 96 --- src/lib/routes.ts | 10 +- 18 files changed, 741 insertions(+), 1078 deletions(-) create mode 100644 src/app/(app)/customers/components/details-tabs/customer-guardrails-tab.tsx delete mode 100644 src/app/(app)/settings/ai-settings/components/guardrails-tab.tsx create mode 100644 src/app/(app)/settings/ai-settings/components/guardrails/build-policy-groups.ts create mode 100644 src/app/(app)/settings/ai-settings/components/guardrails/guardrails-panel.tsx create mode 100644 src/app/(app)/settings/ai-settings/components/guardrails/guardrails-tab.tsx create mode 100644 src/app/(app)/settings/ai-settings/components/guardrails/guardrails-template-picker.tsx rename src/app/(app)/settings/{types/ai-policies.ts => ai-settings/components/guardrails/guardrails.types.ts} (60%) create mode 100644 src/app/(app)/settings/ai-settings/components/guardrails/use-guardrails-editor.ts create mode 100644 src/app/(app)/settings/ai-settings/components/guardrails/use-guardrails-policies.ts delete mode 100644 src/app/(app)/settings/hooks/.use-ai-policies.md delete mode 100644 src/app/(app)/settings/hooks/use-ai-policies.ts delete mode 100644 src/app/(app)/settings/types/.ai-policies.md delete mode 100644 src/app/(app)/settings/types/ai-settings.ts delete mode 100644 src/app/(app)/settings/utils/ai-settings.utils.tsx diff --git a/src/app/(app)/customers/components/customer-details-view.tsx b/src/app/(app)/customers/components/customer-details-view.tsx index f9dddae..c54cd23 100644 --- a/src/app/(app)/customers/components/customer-details-view.tsx +++ b/src/app/(app)/customers/components/customer-details-view.tsx @@ -69,7 +69,13 @@ export function CustomerDetailsView({ id }: CustomerDetailsViewProps) { const isSaasTenant = runtimeEnv.appMode() === 'saas-tenant'; const { view: clientView } = useClientView(id, { enabled: isCustomerAiEnabled && isSaasTenant && !!id }); const showCustomAiAssistant = isCustomerAiEnabled && isSaasTenant && !!clientView; - const tabs = useMemo(() => getCustomerTabs(showCustomAiAssistant), [showCustomAiAssistant]); + // Guardrails are tenant-wide defaults shown read-only per customer; no + // per-customer override exists backend-side yet, so no data precondition. + const showGuardrails = isCustomerAiEnabled && isSaasTenant; + const tabs = useMemo( + () => getCustomerTabs({ showCustomAiAssistant, showGuardrails }), + [showCustomAiAssistant, showGuardrails], + ); const activeTab = tabs.some(tab => tab.id === requestedTab) ? requestedTab : 'devices'; // Register this organization as the Mingo "open view". diff --git a/src/app/(app)/customers/components/details-tabs/customer-guardrails-tab.tsx b/src/app/(app)/customers/components/details-tabs/customer-guardrails-tab.tsx new file mode 100644 index 0000000..d80f267 --- /dev/null +++ b/src/app/(app)/customers/components/details-tabs/customer-guardrails-tab.tsx @@ -0,0 +1,45 @@ +'use client'; + +import { InfoCircleIcon, PenEditIcon } from '@flamingo-stack/openframe-frontend-core/components/icons-v2'; +import { Button } from '@flamingo-stack/openframe-frontend-core/components/ui'; +import { useRouter } from 'next/navigation'; +import { GuardrailsPanel } from '@/app/(app)/settings/ai-settings/components/guardrails/guardrails-panel'; +import { useGuardrailsEditor } from '@/app/(app)/settings/ai-settings/components/guardrails/use-guardrails-editor'; +import { routes } from '@/lib/routes'; + +/** + * Read-only "Customer AI Guardrails" tab on the customer details page. The + * backend has no per-organization policies yet, so every customer follows the + * tenant-wide defaults — this shows them (real data, tenant-scoped) with a + * pointer to AI Settings → Guardrails for editing. Once org-scoped policies + * land, the scope enters via the guardrails data hooks and this banner becomes + * conditional on an override existing. + */ +export function CustomerGuardrailsTab() { + const router = useRouter(); + const editor = useGuardrailsEditor({ isEditMode: false }); + + return ( +
+
+
+ +
+

Using Default Settings

+

This customer follows guardrails defaults.

+
+
+ +
+ + +
+ ); +} diff --git a/src/app/(app)/customers/components/details-tabs/customer-tabs.tsx b/src/app/(app)/customers/components/details-tabs/customer-tabs.tsx index aec838b..629029a 100644 --- a/src/app/(app)/customers/components/details-tabs/customer-tabs.tsx +++ b/src/app/(app)/customers/components/details-tabs/customer-tabs.tsx @@ -6,6 +6,7 @@ import { ClockHistoryIcon, FileContentIcon, MonitorIcon, + RewardBadgeCheckIcon, TagIcon, } from '@flamingo-stack/openframe-frontend-core/components/icons-v2'; import type { TabItem } from '@flamingo-stack/openframe-frontend-core/components/ui'; @@ -14,6 +15,7 @@ import type { CustomerDetails } from '../../hooks/use-customer-details'; import { CustomerCustomAiAssistantTab } from './customer-custom-ai-assistant-tab'; import { CustomerDetailsTab } from './customer-details-tab'; import { CustomerDevicesTab } from './customer-devices-tab'; +import { CustomerGuardrailsTab } from './customer-guardrails-tab'; import { CustomerLogsTab } from './customer-logs-tab'; import { CustomerTicketsTab } from './customer-tickets-tab'; import { CustomerWorktimeTab } from './customer-worktime-tab'; @@ -52,7 +54,12 @@ function CustomAiAssistantTab({ organization }: CustomerTabProps) { return ; } +function GuardrailsTab(_props: CustomerTabProps) { + return ; +} + export const CUSTOM_AI_ASSISTANT_TAB_ID = 'custom-ai-assistant'; +export const CUSTOMER_GUARDRAILS_TAB_ID = 'customer-ai-guardrails'; const BASE_CUSTOMER_TABS: TabItem[] = [ { id: 'devices', label: 'Devices', icon: MonitorIcon, component: DevicesTab }, @@ -69,15 +76,29 @@ const CUSTOM_AI_ASSISTANT_TAB: TabItem = { component: CustomAiAssistantTab, }; +const CUSTOMER_GUARDRAILS_TAB: TabItem = { + id: CUSTOMER_GUARDRAILS_TAB_ID, + label: 'Customer AI Guardrails', + icon: RewardBadgeCheckIcon, + component: GuardrailsTab, +}; + // Superset used to resolve the active tab's component regardless of visibility. -const ALL_CUSTOMER_TABS: TabItem[] = [...BASE_CUSTOMER_TABS, CUSTOM_AI_ASSISTANT_TAB]; - -/** - * Tabs shown for a customer. The Custom AI Assistant tab is appended only when - * the customer has a custom appearance override (`showCustomAiAssistant`). - */ -export const getCustomerTabs = (showCustomAiAssistant: boolean): TabItem[] => - showCustomAiAssistant ? ALL_CUSTOMER_TABS : BASE_CUSTOMER_TABS; +const ALL_CUSTOMER_TABS: TabItem[] = [...BASE_CUSTOMER_TABS, CUSTOM_AI_ASSISTANT_TAB, CUSTOMER_GUARDRAILS_TAB]; + +interface CustomerTabsVisibility { + /** Appended only when the customer has a custom appearance override. */ + showCustomAiAssistant: boolean; + /** Read-only guardrails defaults view (saas-tenant, flag-gated). */ + showGuardrails: boolean; +} + +/** Tabs shown for a customer — base tabs plus the visibility-gated AI tabs. */ +export const getCustomerTabs = ({ showCustomAiAssistant, showGuardrails }: CustomerTabsVisibility): TabItem[] => [ + ...BASE_CUSTOMER_TABS, + ...(showCustomAiAssistant ? [CUSTOM_AI_ASSISTANT_TAB] : []), + ...(showGuardrails ? [CUSTOMER_GUARDRAILS_TAB] : []), +]; export const getCustomerTab = (tabId: string): TabItem | undefined => ALL_CUSTOMER_TABS.find(tab => tab.id === tabId); diff --git a/src/app/(app)/settings/ai-settings/components/ai-settings-view.tsx b/src/app/(app)/settings/ai-settings/components/ai-settings-view.tsx index 78fcc8a..66b2f31 100644 --- a/src/app/(app)/settings/ai-settings/components/ai-settings-view.tsx +++ b/src/app/(app)/settings/ai-settings/components/ai-settings-view.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Button, Skeleton } from '@flamingo-stack/openframe-frontend-core/components/ui'; +import { LoadError, Skeleton } from '@flamingo-stack/openframe-frontend-core/components/ui'; import { useToast } from '@flamingo-stack/openframe-frontend-core/hooks'; import { useSearchParams } from 'next/navigation'; import { useCallback, useState } from 'react'; @@ -24,7 +24,7 @@ import { useAiSettingsActions } from './ai-settings-actions'; import { AiSettingsLayout } from './ai-settings-layout'; import { type AiSettingsTabId, AiSettingsTabs, getVisibleAiSettingsTabs } from './ai-settings-tabs'; import { CUSTOMER_AI_ASSISTANT_FORM_ID, CustomerAiAssistantTab } from './customer-ai-assistant-tab'; -import { GUARDRAILS_FORM_ID, GuardrailsTab } from './guardrails-tab'; +import { GUARDRAILS_FORM_ID, GuardrailsTab } from './guardrails/guardrails-tab'; import { MingoAiChatTab } from './mingo-ai-chat-tab'; const FORM_ID_BY_TAB: Record = { @@ -189,14 +189,10 @@ export function AiSettings() { // defaults, so real settings can't be overwritten on save. if (hasLoadError) { return ( -
-

- Couldn't load AI settings. The service may be temporarily unavailable. -

- -
+ ); } diff --git a/src/app/(app)/settings/ai-settings/components/guardrails-tab.tsx b/src/app/(app)/settings/ai-settings/components/guardrails-tab.tsx deleted file mode 100644 index 3598dd6..0000000 --- a/src/app/(app)/settings/ai-settings/components/guardrails-tab.tsx +++ /dev/null @@ -1,615 +0,0 @@ -'use client'; - -import type { ApprovalLevel, PermissionCategory } from '@flamingo-stack/openframe-frontend-core'; -import { - Alert, - AlertDescription, - Button, - Label, - RadioGroupBlock, - Skeleton, - SlidersIcon, -} from '@flamingo-stack/openframe-frontend-core'; -import { PolicyConfigurationPanel } from '@flamingo-stack/openframe-frontend-core/components/features'; -import { useToast } from '@flamingo-stack/openframe-frontend-core/hooks'; -import { AlertCircle } from 'lucide-react'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { InfoCell } from '@/app/components/shared/info-cell'; -import { apiClient } from '@/lib/api-client'; -import { useAiPolicies } from '../../hooks/use-ai-policies'; -import { CUSTOM_CREATION_TEMPLATE_ID, type PolicyRule, type PolicyTemplateDetail } from '../../types/ai-policies'; -import type { CustomPolicyState, EditSnapshot } from '../../types/ai-settings'; -import { buildPolicyGroups, clonePolicyGroups, mapToObject } from '../../utils/ai-settings.utils'; - -const CUSTOM_TEMPLATE_TYPE = 'CUSTOM' as const; - -export const GUARDRAILS_FORM_ID = 'ai-settings-guardrails-form'; - -interface GuardrailsTabProps { - /** Driven by the shared AI Settings edit mode. */ - isEditMode: boolean; - /** Called after a successful save so the parent can exit edit mode. */ - onSaved: () => void; -} - -// Self-contained legacy tab: renders read-only or editable content based on -// `isEditMode`, and exposes its edit form via GUARDRAILS_FORM_ID for the shared Save. -export function GuardrailsTab({ isEditMode, onSaved }: GuardrailsTabProps) { - const { toast } = useToast(); - // Set right before `onSaved()` so the edit-mode effect skips the cancel-revert. - const savedRef = useRef(false); - - const { - templateOptions, - selectedTemplateId, - setSelectedTemplateId, - selectedTemplate, - isLoading: isPoliciesLoading, - isLoadingTemplate: isPolicyTemplateLoading, - activeTemplateId, - activateTemplate, - createOrUpdateCustomPolicy, - refetchSelectedTemplate, - } = useAiPolicies(); - - const [isFetchingBaseTemplate, setIsFetchingBaseTemplate] = useState(false); - const isSubmittingRef = useRef(false); - - const customTemplate = useMemo(() => templateOptions.find(t => t.type === CUSTOM_TEMPLATE_TYPE), [templateOptions]); - const hasCustomTemplate = !!customTemplate; - - // Important: rely on the currently selected radio option, not on `selectedTemplate` (which can lag behind while fetching). - const selectedTemplateOption = useMemo( - () => templateOptions.find(t => t.id === selectedTemplateId), - [templateOptions, selectedTemplateId], - ); - const isSelectedCustomTemplate = selectedTemplateOption?.type === CUSTOM_TEMPLATE_TYPE; - const canEditPolicyRules = - isEditMode && (selectedTemplateId === CUSTOM_CREATION_TEMPLATE_ID || isSelectedCustomTemplate); - - // Label of the active guardrails preset, shown in the read-only summary. - const selectedPresetLabel = useMemo(() => { - const currentTemplateId = selectedTemplateId || activeTemplateId; - return templateOptions.find(t => t.id === currentTemplateId)?.label || 'None'; - }, [templateOptions, selectedTemplateId, activeTemplateId]); - - const [policyGroups, setPolicyGroups] = useState>(new Map()); - - const emptyCustomPolicyState: CustomPolicyState = useMemo( - () => ({ - enabled: false, - baseTemplateId: null, - originalRules: new Map(), - changes: new Map(), - existingOverrides: {}, - baseTemplateForDisplay: null, - }), - [], - ); - - const [customPolicy, setCustomPolicy] = useState(emptyCustomPolicyState); - - const editSnapshotRef = useRef(null); - - // Latest pending rule edits, readable from the rebuild effect without - // re-running it (and collapsing the panel) on every permission change. - const customChangesRef = useRef(customPolicy.changes); - customChangesRef.current = customPolicy.changes; - - // Which template the current policyGroups were built from; session-only UI - // state (expansion, global permissions) must not survive a template switch. - const groupsSourceIdRef = useRef(null); - - useEffect(() => { - const templateToDisplay = - customPolicy.enabled && customPolicy.baseTemplateForDisplay - ? customPolicy.baseTemplateForDisplay - : selectedTemplate; - - if (!templateToDisplay?.rules) { - setPolicyGroups(new Map()); - return; - } - - if ( - selectedTemplate?.type === CUSTOM_TEMPLATE_TYPE && - selectedTemplateId !== CUSTOM_CREATION_TEMPLATE_ID && - !customPolicy.enabled - ) { - const rulesMap = new Map(); - selectedTemplate.rules.forEach((rule: PolicyRule) => { - rulesMap.set(rule.naturalKey, rule.approvalLevel); - }); - - const existingSourceTemplate = selectedTemplate.sourceTemplate || null; - if (isEditMode && !editSnapshotRef.current?.customBaseTemplateId && editSnapshotRef.current) { - editSnapshotRef.current.customBaseTemplateId = existingSourceTemplate; - } - - setCustomPolicy({ - enabled: true, - baseTemplateId: existingSourceTemplate, - originalRules: rulesMap, - changes: new Map(), - existingOverrides: (selectedTemplate.customOverrides as Record) || {}, - baseTemplateForDisplay: null, - }); - } - - // Rebuild from the template rules. While the same template stays on - // screen, keep what the user already did this session: expanded - // categories, pending rule edits and (in edit mode only) chosen global - // permissions — background refetches must not visually revert those. - // Switching templates or leaving edit mode drops session-only state so - // the preview always matches what a reload would show. - const sourceId = templateToDisplay.id; - const isSameSource = groupsSourceIdRef.current === sourceId; - groupsSourceIdRef.current = sourceId; - const changes = customChangesRef.current; - setPolicyGroups(prev => { - const next = buildPolicyGroups(templateToDisplay.rules as PolicyRule[]); - for (const [groupName, categories] of next) { - const prevCategories = isSameSource ? prev.get(groupName) : undefined; - next.set( - groupName, - categories.map(cat => { - const prevCat = prevCategories?.find(c => c.id === cat.id); - return { - ...cat, - isExpanded: prevCat?.isExpanded ?? cat.isExpanded, - globalPermission: isEditMode ? prevCat?.globalPermission : undefined, - policies: cat.policies.map(p => { - const pendingLevel = changes.get(p.naturalKey); - return pendingLevel ? { ...p, approvalLevel: pendingLevel } : p; - }), - }; - }), - ); - } - return next; - }); - }, [customPolicy.baseTemplateForDisplay, customPolicy.enabled, selectedTemplate, selectedTemplateId, isEditMode]); - - const resetCustomPolicyState = useCallback(() => { - setCustomPolicy(emptyCustomPolicyState); - }, [emptyCustomPolicyState]); - - const handleSave = useCallback(async () => { - // Guard against submissions outside an edit session (e.g. stray form - // submits) and against double-submits while a save is in flight. - if (!isEditMode || isSubmittingRef.current) return; - - const savePromises: Promise[] = []; - - const snapshot = editSnapshotRef.current; - - const hasCustomChanges = customPolicy.changes.size > 0; - const isEditingCustomTemplate = selectedTemplate?.type === CUSTOM_TEMPLATE_TYPE; - const isCreatingNewCustomPolicy = customPolicy.enabled && customPolicy.baseTemplateId && !hasCustomTemplate; - - const baseTemplateChanged = - customPolicy.enabled && - !!customPolicy.baseTemplateId && - !!snapshot?.customBaseTemplateId && - customPolicy.baseTemplateId !== snapshot.customBaseTemplateId; - - const shouldSaveCustomPolicy = - isCreatingNewCustomPolicy || - (customPolicy.enabled && hasCustomChanges) || - (isEditingCustomTemplate && hasCustomChanges) || - baseTemplateChanged; - - if (shouldSaveCustomPolicy) { - const overrides: Record = baseTemplateChanged - ? mapToObject(customPolicy.changes) - : { ...customPolicy.existingOverrides, ...mapToObject(customPolicy.changes) }; - - let templateIdForUpdate: string | null = null; - - if (customPolicy.baseTemplateId) { - templateIdForUpdate = customPolicy.baseTemplateId; - } else if (isEditingCustomTemplate) { - const nonCustomTemplate = templateOptions.find(t => t.type !== CUSTOM_TEMPLATE_TYPE); - templateIdForUpdate = nonCustomTemplate?.id || 'DEFAULT'; - } - - if (templateIdForUpdate) { - savePromises.push( - createOrUpdateCustomPolicy(templateIdForUpdate, overrides).then(async () => { - resetCustomPolicyState(); - - try { - await refetchSelectedTemplate(); - } catch (_error) {} - }), - ); - } - } else { - const policyChanged = - selectedTemplateId && - selectedTemplateId !== CUSTOM_CREATION_TEMPLATE_ID && - selectedTemplateId !== (snapshot?.templateId || activeTemplateId); - - if (policyChanged) { - savePromises.push( - activateTemplate(selectedTemplateId).then(() => { - if (editSnapshotRef.current) editSnapshotRef.current.templateId = selectedTemplateId; - }), - ); - } - } - - if (savePromises.length > 0) { - isSubmittingRef.current = true; - try { - await Promise.all(savePromises); - savedRef.current = true; - onSaved(); - } catch (_error) { - } finally { - isSubmittingRef.current = false; - } - } else { - savedRef.current = true; - onSaved(); - } - }, [ - isEditMode, - onSaved, - activateTemplate, - activeTemplateId, - createOrUpdateCustomPolicy, - customPolicy, - hasCustomTemplate, - refetchSelectedTemplate, - resetCustomPolicyState, - selectedTemplate, - selectedTemplateId, - templateOptions, - ]); - - const revertEdits = () => { - const snapshot = editSnapshotRef.current; - if (snapshot) { - setSelectedTemplateId(snapshot.templateId || activeTemplateId || null); - setPolicyGroups(clonePolicyGroups(snapshot.policyGroups)); - } else { - setSelectedTemplateId(activeTemplateId || null); - } - - resetCustomPolicyState(); - }; - - const beginEditSession = useCallback(() => { - const currentTemplate = templateOptions.find(t => t.id === (selectedTemplateId || activeTemplateId)); - const customBaseTemplateId = - currentTemplate?.type === CUSTOM_TEMPLATE_TYPE - ? selectedTemplate?.sourceTemplate || customPolicy.baseTemplateId || null - : customPolicy.baseTemplateId || null; - - editSnapshotRef.current = { - templateId: selectedTemplateId || activeTemplateId || null, - policyGroups: clonePolicyGroups(policyGroups), - customBaseTemplateId, - }; - }, [ - activeTemplateId, - customPolicy.baseTemplateId, - policyGroups, - selectedTemplate, - selectedTemplateId, - templateOptions, - ]); - - // Shared edit mode drives the session: snapshot on enter, revert on cancel - // (skipped right after a save). Refs keep the latest handlers without - // re-running this on every state change. - const beginEditSessionRef = useRef(beginEditSession); - beginEditSessionRef.current = beginEditSession; - const revertEditsRef = useRef(revertEdits); - revertEditsRef.current = revertEdits; - const prevEditModeRef = useRef(isEditMode); - - useEffect(() => { - const prev = prevEditModeRef.current; - prevEditModeRef.current = isEditMode; - if (!prev && isEditMode) { - beginEditSessionRef.current(); - } else if (prev && !isEditMode) { - if (savedRef.current) savedRef.current = false; - else revertEditsRef.current(); - } - }, [isEditMode]); - - const setupCustomPolicy = useCallback( - (baseTemplate: PolicyTemplateDetail) => { - const rulesMap = new Map(); - baseTemplate.rules.forEach((rule: PolicyRule) => { - rulesMap.set(rule.naturalKey, rule.approvalLevel); - }); - - if (isEditMode && editSnapshotRef.current && !editSnapshotRef.current.customBaseTemplateId) { - editSnapshotRef.current.customBaseTemplateId = baseTemplate.id; - } - - setCustomPolicy(prev => ({ - ...prev, - enabled: true, - baseTemplateId: baseTemplate.id, - originalRules: rulesMap, - changes: new Map(), - baseTemplateForDisplay: baseTemplate, - })); - }, - [isEditMode], - ); - - const handleUseForCustomPolicy = useCallback( - async (templateId: string) => { - setIsFetchingBaseTemplate(true); - try { - const res = await apiClient.get( - `/chat/api/v1/policies/${encodeURIComponent(templateId)}`, - ); - if (!res.ok) throw new Error(res.error || 'Failed to fetch base template'); - - const baseTemplate = res.data; - if (baseTemplate) { - setupCustomPolicy(baseTemplate); - - if (customTemplate) { - setSelectedTemplateId(customTemplate.id); - setCustomPolicy(prev => ({ ...prev, existingOverrides: {} })); - } else { - setSelectedTemplateId(CUSTOM_CREATION_TEMPLATE_ID); - } - } - } catch (error) { - toast({ - title: 'Failed to Load Base Template', - description: error instanceof Error ? error.message : 'Unable to load template for custom policy', - variant: 'destructive', - duration: 5000, - }); - } finally { - setIsFetchingBaseTemplate(false); - } - }, - [customTemplate, setupCustomPolicy, toast, setSelectedTemplateId], - ); - - const handlePolicyCategoryToggle = (policyGroupName: string, categoryId: string) => { - setPolicyGroups(prev => { - const newGroups = new Map(prev); - const categories = newGroups.get(policyGroupName); - if (categories) { - newGroups.set( - policyGroupName, - categories.map(cat => (cat.id === categoryId ? { ...cat, isExpanded: !cat.isExpanded } : cat)), - ); - } - return newGroups; - }); - }; - - const handlePolicyGlobalPermissionChange = ( - policyGroupName: string, - categoryId: string, - level: ApprovalLevel | undefined, - ) => { - if (!canEditPolicyRules) return; - - setPolicyGroups(prev => { - const newGroups = new Map(prev); - const categories = newGroups.get(policyGroupName); - if (categories) { - newGroups.set( - policyGroupName, - categories.map(cat => { - if (cat.id !== categoryId) return cat; - const updated = { ...cat, globalPermission: level }; - if (level) { - updated.policies = cat.policies.map(p => ({ ...p, approvalLevel: level })); - } - return updated; - }), - ); - } - return newGroups; - }); - - if (customPolicy.enabled && level) { - setCustomPolicy(prev => { - const next = new Map(prev.changes); - const categories = policyGroups.get(policyGroupName); - const category = categories?.find(c => c.id === categoryId); - category?.policies.forEach(p => { - const originalLevel = prev.originalRules.get(p.naturalKey); - if (originalLevel === level) next.delete(p.naturalKey); - else next.set(p.naturalKey, level); - }); - return { ...prev, changes: next }; - }); - } - }; - - const handlePolicyPermissionChange = ( - policyGroupName: string, - categoryId: string, - policyId: string, - level: ApprovalLevel, - ) => { - if (!canEditPolicyRules) return; - - setPolicyGroups(prev => { - const newGroups = new Map(prev); - const categories = newGroups.get(policyGroupName); - if (categories) { - newGroups.set( - policyGroupName, - categories.map(cat => - cat.id === categoryId - ? { - ...cat, - policies: cat.policies.map(p => (p.id === policyId ? { ...p, approvalLevel: level } : p)), - } - : cat, - ), - ); - } - return newGroups; - }); - - if (customPolicy.enabled) { - const naturalKey = policyId; - setCustomPolicy(prev => { - const next = new Map(prev.changes); - const originalLevel = prev.originalRules.get(naturalKey); - if (originalLevel === level) next.delete(naturalKey); - else next.set(naturalKey, level); - return { ...prev, changes: next }; - }); - } - }; - - return ( - // Edit mode + Save are owned by the shared AiSettingsLayout actions; - // the shared Save submits this form via GUARDRAILS_FORM_ID. -
{ - event.preventDefault(); - void handleSave(); - }} - className="space-y-6" - > - {/* Selected guardrails preset (read-only summary) */} - {!isEditMode && - (isPoliciesLoading ? ( - - ) : ( -
- -
- ))} - - {/* Guardrails policies */} -
- {isPoliciesLoading ? ( -
- - -
- ) : templateOptions.length === 0 ? ( - - - No policy templates available. - - ) : ( - <> - {/* Template chooser (shown only in edit mode) */} - {isEditMode && ( - { - if (v === CUSTOM_CREATION_TEMPLATE_ID) return; - - const selectedOpt = templateOptions.find(t => t.id === v); - const isSelectingCustomType = selectedOpt?.type === CUSTOM_TEMPLATE_TYPE; - - setSelectedTemplateId(v); - if (!isSelectingCustomType) resetCustomPolicyState(); - }} - disabled={isPolicyTemplateLoading || isFetchingBaseTemplate} - options={[ - ...templateOptions.map(opt => { - const isCustomType = opt.type === CUSTOM_TEMPLATE_TYPE; - - let labelSuffix = ''; - if (isCustomType) { - if (customPolicy.enabled && customPolicy.baseTemplateId) { - const baseTemplate = templateOptions.find(t => t.id === customPolicy.baseTemplateId); - if (baseTemplate) labelSuffix = ` (based on ${baseTemplate.label})`; - } else if (selectedTemplate?.sourceTemplate) { - const sourceTemplate = templateOptions.find(t => t.id === selectedTemplate.sourceTemplate); - if (sourceTemplate) labelSuffix = ` (based on ${sourceTemplate.label})`; - } - } - - return { - value: opt.id, - label: `${opt.label}${labelSuffix}`, - description: opt.description, - trailing: !isCustomType ? ( - - ) : undefined, - }; - }), - ...(customPolicy.enabled && !hasCustomTemplate - ? [ - { - value: CUSTOM_CREATION_TEMPLATE_ID, - label: `Custom Policy${ - customPolicy.baseTemplateId - ? ` (based on ${templateOptions.find(t => t.id === customPolicy.baseTemplateId)?.label})` - : '' - }`, - }, - ] - : []), - ]} - /> - )} - - {isPolicyTemplateLoading ? ( - - ) : policyGroups.size === 0 ? ( - - - - This policy template has no rules. - - - ) : ( -
- {Array.from(policyGroups.entries()).map(([policyGroupName, categories]) => ( -
- - handlePolicyCategoryToggle(policyGroupName, categoryId)} - onGlobalPermissionChange={(categoryId, level) => - handlePolicyGlobalPermissionChange(policyGroupName, categoryId, level) - } - onPolicyPermissionChange={(categoryId, policyId, level) => - handlePolicyPermissionChange(policyGroupName, categoryId, policyId, level) - } - /> -
- ))} -
- )} - - )} -
- - ); -} diff --git a/src/app/(app)/settings/ai-settings/components/guardrails/build-policy-groups.ts b/src/app/(app)/settings/ai-settings/components/guardrails/build-policy-groups.ts new file mode 100644 index 0000000..027d469 --- /dev/null +++ b/src/app/(app)/settings/ai-settings/components/guardrails/build-policy-groups.ts @@ -0,0 +1,56 @@ +import type { PermissionCategory } from '@flamingo-stack/openframe-frontend-core'; +import { normalizeToolTypeWithFallback } from '@flamingo-stack/openframe-frontend-core/utils'; +import type { PolicyRule } from './guardrails.types'; + +function slugify(value: string) { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +/** + * Groups flat policy rules into policy-group → categories → policies for the + * core-lib PolicyConfigurationPanel. Pure data transform — presentation state + * (expansion, bulk selections) is owned by the panel. + */ +export function buildPolicyGroups(rules: PolicyRule[]): Map { + const groupedByPolicyGroup = new Map>(); + + for (const rule of rules) { + const policyGroupName = rule.policyGroup || 'General'; + const categoryName = rule.category || 'Other'; + const categoryId = slugify(`${policyGroupName}:${categoryName}`) || 'other'; + + let categoriesMap = groupedByPolicyGroup.get(policyGroupName); + if (!categoriesMap) { + categoriesMap = new Map(); + groupedByPolicyGroup.set(policyGroupName, categoriesMap); + } + + let category = categoriesMap.get(categoryId); + if (!category) { + category = { id: categoryId, name: categoryName, policies: [] }; + categoriesMap.set(categoryId, category); + } + + category.policies.push({ + id: rule.naturalKey, + naturalKey: rule.naturalKey, + name: rule.operation || rule.naturalKey, + commandPattern: rule.commandPattern, + toolName: normalizeToolTypeWithFallback(rule.tool), + approvalLevel: rule.approvalLevel, + }); + } + + const finalGroups = new Map(); + for (const [policyGroupName, categoriesMap] of groupedByPolicyGroup) { + finalGroups.set( + policyGroupName, + Array.from(categoriesMap.values()).sort((a, b) => a.name.localeCompare(b.name)), + ); + } + + return finalGroups; +} diff --git a/src/app/(app)/settings/ai-settings/components/guardrails/guardrails-panel.tsx b/src/app/(app)/settings/ai-settings/components/guardrails/guardrails-panel.tsx new file mode 100644 index 0000000..bbcfb23 --- /dev/null +++ b/src/app/(app)/settings/ai-settings/components/guardrails/guardrails-panel.tsx @@ -0,0 +1,107 @@ +'use client'; + +import { LoadError, NoData, Skeleton } from '@flamingo-stack/openframe-frontend-core'; +import { PolicyConfigurationPanel } from '@flamingo-stack/openframe-frontend-core/components/features'; +import { ShieldCheckIcon } from '@flamingo-stack/openframe-frontend-core/components/icons-v2'; +import { EmptyState } from '@/app/components/shared/empty-state'; +import { InfoCell } from '@/app/components/shared/info-cell'; +import { GuardrailsTemplatePicker } from './guardrails-template-picker'; +import type { GuardrailsEditor } from './use-guardrails-editor'; + +interface GuardrailsPanelProps { + editor: GuardrailsEditor; + isEditMode: boolean; + /** + * Renders the preset value muted — the shown policies are tenant-wide + * defaults inherited by the current scope (customer details usage). + */ + inheritedPreset?: boolean; +} + +/** + * Host-agnostic guardrails policy panel: read-only preset summary or the + * edit-mode template picker, plus the grouped policy rules. Hosted by the AI + * Settings guardrails tab today; designed to be mounted on the customer + * details page once the backend supports per-organization policies (the org + * scope enters via the data hooks, not here). + */ +export function GuardrailsPanel({ editor, isEditMode, inheritedPreset = false }: GuardrailsPanelProps) { + if (editor.isLoading) { + return ( +
+ + +
+ ); + } + + if (editor.loadError) { + return ( + void editor.refetch()} + /> + ); + } + + if (!editor.hasTemplates) { + return ( + } + title="No policy templates available" + description="Guardrails policy templates will appear here once the AI service provides them." + /> + ); + } + + return ( +
+ {isEditMode ? ( + + ) : ( +
+ {editor.activePresetLabel} + ) : ( + editor.activePresetLabel + ) + } + label="Guardrails Preset" + /> +
+ )} + + {editor.isDetailLoading ? ( + + ) : editor.policyGroups.size === 0 ? ( + } + title="This policy template has no rules" + className="py-[var(--spacing-system-xxl)]" + /> + ) : ( +
+ {Array.from(editor.policyGroups.entries()).map(([policyGroupName, categories]) => ( +
+

{policyGroupName}

+ +
+ ))} +
+ )} +
+ ); +} diff --git a/src/app/(app)/settings/ai-settings/components/guardrails/guardrails-tab.tsx b/src/app/(app)/settings/ai-settings/components/guardrails/guardrails-tab.tsx new file mode 100644 index 0000000..5de4d1b --- /dev/null +++ b/src/app/(app)/settings/ai-settings/components/guardrails/guardrails-tab.tsx @@ -0,0 +1,33 @@ +'use client'; + +import { GuardrailsPanel } from './guardrails-panel'; +import { useGuardrailsEditor } from './use-guardrails-editor'; + +export const GUARDRAILS_FORM_ID = 'ai-settings-guardrails-form'; + +interface GuardrailsTabProps { + /** Driven by the shared AI Settings edit mode. */ + isEditMode: boolean; + /** Called after a successful save so the parent can exit edit mode. */ + onSaved: () => void; +} + +// Edit mode + Save are owned by the shared AiSettingsLayout actions; the +// shared Save submits this form via GUARDRAILS_FORM_ID. +export function GuardrailsTab({ isEditMode, onSaved }: GuardrailsTabProps) { + const editor = useGuardrailsEditor({ isEditMode }); + + return ( +
{ + event.preventDefault(); + void editor.save().then(saved => { + if (saved) onSaved(); + }); + }} + > + + + ); +} diff --git a/src/app/(app)/settings/ai-settings/components/guardrails/guardrails-template-picker.tsx b/src/app/(app)/settings/ai-settings/components/guardrails/guardrails-template-picker.tsx new file mode 100644 index 0000000..966aa25 --- /dev/null +++ b/src/app/(app)/settings/ai-settings/components/guardrails/guardrails-template-picker.tsx @@ -0,0 +1,53 @@ +'use client'; + +import { Button, RadioGroupBlock, SlidersIcon } from '@flamingo-stack/openframe-frontend-core'; +import type { GuardrailsTemplateOption } from './use-guardrails-editor'; + +interface GuardrailsTemplatePickerProps { + options: GuardrailsTemplateOption[]; + value: string; + disabled?: boolean; + onSelect: (templateId: string) => void; + /** "Use for Custom Policy" on a stock template row. */ + onCreateCustomPolicyFrom: (baseTemplateId: string) => void; +} + +/** Edit-mode template chooser: one radio per template, stock rows offer "Use for Custom Policy". */ +export function GuardrailsTemplatePicker({ + options, + value, + disabled, + onSelect, + onCreateCustomPolicyFrom, +}: GuardrailsTemplatePickerProps) { + return ( + ({ + value: option.id, + label: option.label, + description: option.description, + trailing: !option.isCustom ? ( + + ) : undefined, + }))} + /> + ); +} diff --git a/src/app/(app)/settings/types/ai-policies.ts b/src/app/(app)/settings/ai-settings/components/guardrails/guardrails.types.ts similarity index 60% rename from src/app/(app)/settings/types/ai-policies.ts rename to src/app/(app)/settings/ai-settings/components/guardrails/guardrails.types.ts index 77ce179..e99f0fb 100644 --- a/src/app/(app)/settings/types/ai-policies.ts +++ b/src/app/(app)/settings/ai-settings/components/guardrails/guardrails.types.ts @@ -1,5 +1,15 @@ import type { ApprovalLevel } from '@flamingo-stack/openframe-frontend-core'; +/** + * DTOs for the ai-agent policy REST API (`/chat/api/v1/policies`). + * Guardrails policies are tenant-scoped today; when the backend adds + * per-organization overrides (mirroring ClientAgentSettings), the scope enters + * through `use-guardrails-policies.ts` — these shapes stay unchanged. + */ + +export const CUSTOM_POLICY_TYPE = 'CUSTOM' as const; + +/** Radio value for a custom policy that is being created and has no id yet. */ export const CUSTOM_CREATION_TEMPLATE_ID = 'CUSTOM_CREATION' as const; export interface PolicyTemplateSummary { @@ -26,9 +36,10 @@ export interface PolicyTemplateDetail { id: string; displayName: string; type: 'TEMPLATE' | 'CUSTOM' | string; + /** For CUSTOM policies: id of the template the overrides are based on. */ sourceTemplate?: string; rules: PolicyRule[]; - customOverrides: Record; + customOverrides: Record; active: boolean; } diff --git a/src/app/(app)/settings/ai-settings/components/guardrails/use-guardrails-editor.ts b/src/app/(app)/settings/ai-settings/components/guardrails/use-guardrails-editor.ts new file mode 100644 index 0000000..57fa1e9 --- /dev/null +++ b/src/app/(app)/settings/ai-settings/components/guardrails/use-guardrails-editor.ts @@ -0,0 +1,248 @@ +'use client'; + +import type { ApprovalLevel } from '@flamingo-stack/openframe-frontend-core'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { buildPolicyGroups } from './build-policy-groups'; +import { CUSTOM_CREATION_TEMPLATE_ID, CUSTOM_POLICY_TYPE } from './guardrails.types'; +import { + useActivateGuardrailsTemplate, + useGuardrailsTemplate, + useGuardrailsTemplates, + useSaveCustomGuardrailsPolicy, +} from './use-guardrails-policies'; + +/** + * Everything the user changed during an edit session. Server state stays in + * react-query; cancelling an edit is just dropping the draft — no snapshots. + * + * - `template` a stock template is selected (activated on save) + * - `existing-custom` the tenant's custom policy is selected; `edits` are + * merged into its overrides on save + * - `new-custom` "Use for Custom Policy" re-bases (or creates) the custom + * policy on `baseTemplateId`; `edits` become its overrides + */ +type GuardrailsDraft = + | { kind: 'template'; templateId: string } + | { kind: 'existing-custom'; edits: Map } + | { kind: 'new-custom'; baseTemplateId: string; edits: Map }; + +export interface GuardrailsTemplateOption { + id: string; + label: string; + description?: string; + isCustom: boolean; +} + +interface UseGuardrailsEditorArgs { + /** Edit session driver: entering builds a draft, leaving discards it. */ + isEditMode: boolean; +} + +export function useGuardrailsEditor({ isEditMode }: UseGuardrailsEditorArgs) { + const { templates, activeTemplateId, customTemplate, isLoading, error, refetch } = useGuardrailsTemplates(); + const { activate, isPending: isActivating } = useActivateGuardrailsTemplate(); + const { saveCustomPolicy, isPending: isSavingCustom } = useSaveCustomGuardrailsPolicy(); + + const [draft, setDraft] = useState(null); + + // What the read-only view shows (and the edit draft starts from). + const defaultTemplateId = activeTemplateId ?? templates[0]?.id ?? null; + const customTemplateId = customTemplate?.id ?? null; + + useEffect(() => { + if (!isEditMode) { + setDraft(null); + return; + } + if (!defaultTemplateId) return; // templates still loading + setDraft( + prev => + prev ?? + (customTemplateId === defaultTemplateId + ? { kind: 'existing-custom', edits: new Map() } + : { kind: 'template', templateId: defaultTemplateId }), + ); + }, [isEditMode, defaultTemplateId, customTemplateId]); + + // Which template's rules are on screen. For `existing-custom` that's the + // custom policy itself (rules arrive pre-merged); for `new-custom` the base + // template — react-query caches make switching back and forth instant. + const displayTemplateId = useMemo(() => { + if (!isEditMode || !draft) return defaultTemplateId; + if (draft.kind === 'template') return draft.templateId; + if (draft.kind === 'existing-custom') return customTemplateId; + return draft.baseTemplateId; + }, [isEditMode, draft, defaultTemplateId, customTemplateId]); + + const { template: displayTemplate, isLoading: isDetailLoading } = useGuardrailsTemplate(displayTemplateId); + + // Approval levels as the server knows them — edits that return to these are + // dropped from the draft instead of being sent as overrides. + const baseLevels = useMemo(() => { + const levels = new Map(); + for (const rule of displayTemplate?.rules ?? []) { + levels.set(rule.naturalKey, rule.approvalLevel); + } + return levels; + }, [displayTemplate]); + + const policyGroups = useMemo(() => { + const rules = displayTemplate?.rules ?? []; + const edits = draft && draft.kind !== 'template' ? draft.edits : null; + const effectiveRules = edits?.size + ? rules.map(rule => { + const level = edits.get(rule.naturalKey); + return level ? { ...rule, approvalLevel: level } : rule; + }) + : rules; + return buildPolicyGroups(effectiveRules); + }, [displayTemplate, draft]); + + const allCategories = useMemo(() => Array.from(policyGroups.values()).flat(), [policyGroups]); + + const activePresetLabel = templates.find(t => t.id === defaultTemplateId)?.displayName || 'None'; + + const templateOptions = useMemo(() => { + const basedOnId = + draft?.kind === 'new-custom' + ? draft.baseTemplateId + : draft?.kind === 'existing-custom' + ? displayTemplate?.sourceTemplate + : undefined; + const basedOnLabel = templates.find(t => t.id === basedOnId)?.displayName; + const suffix = basedOnLabel ? ` (based on ${basedOnLabel})` : ''; + + const options: GuardrailsTemplateOption[] = templates.map(t => ({ + id: t.id, + label: t.type === CUSTOM_POLICY_TYPE ? `${t.displayName}${suffix}` : t.displayName, + description: t.description, + isCustom: t.type === CUSTOM_POLICY_TYPE, + })); + + // No custom policy saved yet: the one being drafted gets a synthetic row. + if (draft?.kind === 'new-custom' && !customTemplate) { + options.push({ id: CUSTOM_CREATION_TEMPLATE_ID, label: `Custom Policy${suffix}`, isCustom: true }); + } + + return options; + }, [templates, draft, displayTemplate, customTemplate]); + + const selectedTemplateId = + !draft || draft.kind === 'template' + ? (draft?.templateId ?? defaultTemplateId ?? '') + : (customTemplateId ?? CUSTOM_CREATION_TEMPLATE_ID); + + const canEditRules = isEditMode && !!draft && draft.kind !== 'template'; + + const selectTemplate = useCallback( + (templateId: string) => { + if (templateId === CUSTOM_CREATION_TEMPLATE_ID) return; // synthetic row for the drafted custom policy + setDraft( + templateId === customTemplateId + ? { kind: 'existing-custom', edits: new Map() } + : { kind: 'template', templateId }, + ); + }, + [customTemplateId], + ); + + const createCustomPolicyFrom = useCallback((baseTemplateId: string) => { + setDraft({ kind: 'new-custom', baseTemplateId, edits: new Map() }); + }, []); + + const withEdit = useCallback( + (edits: Map, naturalKey: string, level: ApprovalLevel) => { + if (baseLevels.get(naturalKey) === level) edits.delete(naturalKey); + else edits.set(naturalKey, level); + }, + [baseLevels], + ); + + const setPolicyPermission = useCallback( + (_categoryId: string, policyId: string, level: ApprovalLevel) => { + setDraft(prev => { + if (!prev || prev.kind === 'template') return prev; + const edits = new Map(prev.edits); + withEdit(edits, policyId, level); + return { ...prev, edits }; + }); + }, + [withEdit], + ); + + const applyCategoryPermission = useCallback( + (categoryId: string, level: ApprovalLevel) => { + const category = allCategories.find(c => c.id === categoryId); + if (!category) return; + setDraft(prev => { + if (!prev || prev.kind === 'template') return prev; + const edits = new Map(prev.edits); + for (const policy of category.policies) { + withEdit(edits, policy.naturalKey, level); + } + return { ...prev, edits }; + }); + }, + [allCategories, withEdit], + ); + + // Ref guard: isPending flips asynchronously, a double form submit would + // otherwise fire two mutations. + const isSavingRef = useRef(false); + + /** Persists the draft. Resolves true when the host may exit edit mode. */ + const save = useCallback(async (): Promise => { + if (isSavingRef.current) return false; + if (!draft) return true; // nothing editable (e.g. no templates) + + isSavingRef.current = true; + try { + if (draft.kind === 'template') { + if (draft.templateId !== activeTemplateId) await activate(draft.templateId); + } else if (draft.kind === 'new-custom') { + await saveCustomPolicy({ + templateId: draft.baseTemplateId, + overrides: Object.fromEntries(draft.edits), + }); + } else if (draft.edits.size > 0) { + const baseTemplateId = + displayTemplate?.sourceTemplate || templates.find(t => t.type !== CUSTOM_POLICY_TYPE)?.id || 'DEFAULT'; + await saveCustomPolicy({ + templateId: baseTemplateId, + overrides: { ...(displayTemplate?.customOverrides ?? {}), ...Object.fromEntries(draft.edits) }, + }); + } else if (customTemplateId && customTemplateId !== activeTemplateId) { + // Custom policy re-selected without rule edits — just activate it. + await activate(customTemplateId); + } + return true; + } catch { + return false; // mutation hooks own the error toasts + } finally { + isSavingRef.current = false; + } + }, [draft, activeTemplateId, activate, saveCustomPolicy, displayTemplate, templates, customTemplateId]); + + return { + // Server state + hasTemplates: templates.length > 0, + activePresetLabel, + policyGroups, + isLoading, + loadError: error, + refetch, + isDetailLoading, + // Edit session + templateOptions, + selectedTemplateId, + canEditRules, + isSaving: isActivating || isSavingCustom, + selectTemplate, + createCustomPolicyFrom, + setPolicyPermission, + applyCategoryPermission, + save, + }; +} + +export type GuardrailsEditor = ReturnType; diff --git a/src/app/(app)/settings/ai-settings/components/guardrails/use-guardrails-policies.ts b/src/app/(app)/settings/ai-settings/components/guardrails/use-guardrails-policies.ts new file mode 100644 index 0000000..1b61848 --- /dev/null +++ b/src/app/(app)/settings/ai-settings/components/guardrails/use-guardrails-policies.ts @@ -0,0 +1,136 @@ +'use client'; + +import { useToast } from '@flamingo-stack/openframe-frontend-core/hooks'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useMemo } from 'react'; +import { apiClient } from '@/lib/api-client'; +import { + CUSTOM_POLICY_TYPE, + type CustomPolicyRequest, + type PolicyTemplateDetail, + type PolicyTemplateSummary, +} from './guardrails.types'; + +/** + * React-query data layer for guardrails policy templates + * (`/chat/api/v1/policies`, tenant-scoped). A future per-organization scope + * (customer details page) extends these hooks and their query keys — the + * editor and panel components stay unchanged. + */ + +export const guardrailsQueryKeys = { + all: ['guardrails-policies'] as const, + templates: () => [...guardrailsQueryKeys.all, 'templates'] as const, + template: (id: string) => [...guardrailsQueryKeys.all, 'template', id] as const, +}; + +export function useGuardrailsTemplates() { + const result = useQuery({ + queryKey: guardrailsQueryKeys.templates(), + queryFn: async (): Promise => { + const res = await apiClient.get('/chat/api/v1/policies'); + if (!res.ok) throw new Error(res.error || 'Failed to fetch policy templates'); + // Stock templates first, the tenant's custom policy last. + return (res.data || []).sort((a, b) => { + if (a.type === CUSTOM_POLICY_TYPE && b.type !== CUSTOM_POLICY_TYPE) return 1; + if (a.type !== CUSTOM_POLICY_TYPE && b.type === CUSTOM_POLICY_TYPE) return -1; + return 0; + }); + }, + }); + + const templates = useMemo(() => result.data ?? [], [result.data]); + const activeTemplateId = useMemo(() => templates.find(t => t.isActive)?.id ?? null, [templates]); + const customTemplate = useMemo(() => templates.find(t => t.type === CUSTOM_POLICY_TYPE) ?? null, [templates]); + + return { + templates, + activeTemplateId, + customTemplate, + isLoading: result.isLoading, + error: result.error, + refetch: result.refetch, + }; +} + +export function useGuardrailsTemplate(templateId: string | null) { + const result = useQuery({ + queryKey: guardrailsQueryKeys.template(templateId ?? ''), + queryFn: async (): Promise => { + const res = await apiClient.get( + `/chat/api/v1/policies/${encodeURIComponent(templateId as string)}`, + ); + if (!res.ok || !res.data) throw new Error(res.error || 'Failed to fetch policy template'); + return res.data; + }, + enabled: !!templateId, + }); + + return { + template: result.data ?? null, + isLoading: !!templateId && result.isLoading, + error: result.error, + }; +} + +export function useActivateGuardrailsTemplate() { + const { toast } = useToast(); + const queryClient = useQueryClient(); + + const result = useMutation({ + mutationFn: async (templateId: string) => { + const res = await apiClient.post(`/chat/api/v1/policies/${encodeURIComponent(templateId)}/activate`); + if (!res.ok) throw new Error(res.error || 'Failed to activate policy template'); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: guardrailsQueryKeys.all }); + toast({ + title: 'Guardrails Saved', + description: 'Policy template activated successfully', + variant: 'success', + duration: 4000, + }); + }, + onError: err => { + toast({ + title: 'Save Failed', + description: err instanceof Error ? err.message : 'Unable to activate policy template', + variant: 'destructive', + duration: 5000, + }); + }, + }); + + return { activate: result.mutateAsync, isPending: result.isPending }; +} + +export function useSaveCustomGuardrailsPolicy() { + const { toast } = useToast(); + const queryClient = useQueryClient(); + + const result = useMutation({ + mutationFn: async (request: CustomPolicyRequest) => { + const res = await apiClient.put('/chat/api/v1/policies/custom', request); + if (!res.ok) throw new Error(res.error || 'Failed to save custom policy'); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: guardrailsQueryKeys.all }); + toast({ + title: 'Custom Policy Saved', + description: 'Your custom policy has been saved successfully', + variant: 'success', + duration: 4000, + }); + }, + onError: err => { + toast({ + title: 'Save Failed', + description: err instanceof Error ? err.message : 'Unable to save custom policy', + variant: 'destructive', + duration: 5000, + }); + }, + }); + + return { saveCustomPolicy: result.mutateAsync, isPending: result.isPending }; +} diff --git a/src/app/(app)/settings/hooks/.use-ai-policies.md b/src/app/(app)/settings/hooks/.use-ai-policies.md deleted file mode 100644 index 006667a..0000000 --- a/src/app/(app)/settings/hooks/.use-ai-policies.md +++ /dev/null @@ -1,81 +0,0 @@ - -A React hook for managing AI policy templates and guardrails, providing functionality to fetch, select, activate, and customize policy configurations. - -## Key Components - -- **`useAIPolicies()`** - Main hook that manages AI policy templates state and operations -- **`fetchTemplates()`** - Retrieves all available policy templates from the API -- **`fetchTemplate()`** - Fetches detailed information for a specific policy template -- **`activateTemplate()`** - Activates a selected policy template as the current guardrails -- **`createOrUpdateCustomPolicy()`** - Creates or updates custom policy configurations with approval level overrides -- **`getErrorMessage()`** - Utility function for extracting error messages with fallbacks - -## Usage Example - -```typescript -import { useAIPolicies } from './use-ai-policies' - -function PolicyManagement() { - const { - templates, - selectedTemplate, - selectedTemplateId, - setSelectedTemplateId, - isLoading, - activateTemplate, - createOrUpdateCustomPolicy - } = useAIPolicies() - - const handleActivatePolicy = async () => { - if (selectedTemplateId) { - try { - await activateTemplate(selectedTemplateId) - console.log('Policy activated successfully') - } catch (error) { - console.error('Failed to activate policy:', error) - } - } - } - - const handleCreateCustomPolicy = async () => { - const overrides = { - 'sensitive-data-access': 'manager' as ApprovalLevel, - 'external-api-calls': 'admin' as ApprovalLevel - } - - try { - const customPolicyId = await createOrUpdateCustomPolicy('base-template-id', overrides) - console.log('Custom policy created:', customPolicyId) - } catch (error) { - console.error('Failed to create custom policy:', error) - } - } - - if (isLoading) return
Loading policies...
- - return ( -
- - - - - -
- ) -} -``` - -The hook provides comprehensive state management for AI policy templates, including loading states, error handling with toast notifications, and automatic template synchronization. It supports both predefined templates and custom policy creation with approval level overrides. \ No newline at end of file diff --git a/src/app/(app)/settings/hooks/use-ai-policies.ts b/src/app/(app)/settings/hooks/use-ai-policies.ts deleted file mode 100644 index b17ea25..0000000 --- a/src/app/(app)/settings/hooks/use-ai-policies.ts +++ /dev/null @@ -1,203 +0,0 @@ -import type { ApprovalLevel } from '@flamingo-stack/openframe-frontend-core'; -import { useToast } from '@flamingo-stack/openframe-frontend-core/hooks'; -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { apiClient } from '@/lib/api-client'; - -import { - CUSTOM_CREATION_TEMPLATE_ID, - type CustomPolicyRequest, - type PolicyTemplateDetail, - type PolicyTemplateSummary, -} from '../types/ai-policies'; - -function getErrorMessage(error: unknown, fallback: string) { - return error instanceof Error ? error.message : fallback; -} - -export function useAiPolicies() { - const { toast } = useToast(); - - const [templates, setTemplates] = useState([]); - const [activeTemplateId, setActiveTemplateId] = useState(null); - const [selectedTemplateId, setSelectedTemplateId] = useState(null); - const [selectedTemplate, setSelectedTemplate] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [isLoadingTemplate, setIsLoadingTemplate] = useState(false); - const [isActivating, setIsActivating] = useState(false); - - const fetchTemplates = useCallback(async () => { - try { - const res = await apiClient.get('/chat/api/v1/policies'); - if (!res.ok) throw new Error(res.error || 'Failed to fetch policy templates'); - - const list = (res.data || []).sort((a, b) => { - if (a.type === 'CUSTOM' && b.type !== 'CUSTOM') return 1; - if (a.type !== 'CUSTOM' && b.type === 'CUSTOM') return -1; - return 0; - }); - setTemplates(list); - - const active = list.find(t => t.isActive)?.id || null; - setActiveTemplateId(active); - - setSelectedTemplateId(prev => { - if (prev && list.some(t => t.id === prev)) return prev; - if (active) return active; - return list[0]?.id || null; - }); - } catch (error) { - toast({ - title: 'Failed to Load AI Policies', - description: getErrorMessage(error, 'Unable to load policy templates'), - variant: 'destructive', - duration: 5000, - }); - throw error; - } - }, [toast]); - - const fetchTemplate = useCallback( - async (policyId: string) => { - setIsLoadingTemplate(true); - try { - const res = await apiClient.get(`/chat/api/v1/policies/${encodeURIComponent(policyId)}`); - if (!res.ok) throw new Error(res.error || 'Failed to fetch policy template'); - setSelectedTemplate(res.data || null); - return res.data || null; - } catch (error) { - toast({ - title: 'Failed to Load Policy', - description: getErrorMessage(error, 'Unable to load policy'), - variant: 'destructive', - duration: 5000, - }); - throw error; - } finally { - setIsLoadingTemplate(false); - } - }, - [toast], - ); - - const activateTemplate = useCallback( - async (policyId: string) => { - setIsActivating(true); - try { - const res = await apiClient.post(`/chat/api/v1/policies/${encodeURIComponent(policyId)}/activate`); - if (!res.ok) throw new Error(res.error || 'Failed to activate policy template'); - - toast({ - title: 'Guardrails Saved', - description: 'Policy template activated successfully', - variant: 'success', - duration: 4000, - }); - - await fetchTemplates(); - await fetchTemplate(policyId); - } catch (error) { - toast({ - title: 'Save Failed', - description: getErrorMessage(error, 'Unable to activate policy template'), - variant: 'destructive', - duration: 5000, - }); - throw error; - } finally { - setIsActivating(false); - } - }, - [toast, fetchTemplates, fetchTemplate], - ); - - useEffect(() => { - (async () => { - try { - await fetchTemplates(); - } finally { - setIsLoading(false); - } - })(); - }, [fetchTemplates]); - - useEffect(() => { - if (!selectedTemplateId) { - setSelectedTemplate(null); - return; - } - if (selectedTemplateId === CUSTOM_CREATION_TEMPLATE_ID) return; - - fetchTemplate(selectedTemplateId).catch(() => { - // toasts handled in hook - }); - }, [selectedTemplateId, fetchTemplate]); - - const templateOptions = useMemo( - () => - templates.map(t => ({ - id: t.id, - label: t.displayName, - description: t.description, - isActive: t.isActive, - type: t.type, - })), - [templates], - ); - - const refetchSelectedTemplate = useCallback(() => { - if (selectedTemplateId && selectedTemplateId !== CUSTOM_CREATION_TEMPLATE_ID) { - return fetchTemplate(selectedTemplateId); - } - return Promise.resolve(); - }, [selectedTemplateId, fetchTemplate]); - - const createOrUpdateCustomPolicy = useCallback( - async (baseTemplateId: string, overrides: Record) => { - try { - const requestBody: CustomPolicyRequest = { - templateId: baseTemplateId, - overrides, - }; - - const res = await apiClient.put('/chat/api/v1/policies/custom', requestBody); - if (!res.ok) throw new Error(res.error || 'Failed to save custom policy'); - - toast({ - title: 'Custom Policy Saved', - description: 'Your custom policy has been created successfully', - variant: 'success', - duration: 4000, - }); - - await fetchTemplates(); - - return 'custom'; - } catch (error) { - toast({ - title: 'Save Failed', - description: getErrorMessage(error, 'Unable to save custom policy'), - variant: 'destructive', - duration: 5000, - }); - throw error; - } - }, - [toast, fetchTemplates], - ); - - return { - templates, - templateOptions, - activeTemplateId, - selectedTemplateId, - setSelectedTemplateId, - selectedTemplate, - isLoading, - isLoadingTemplate, - isActivating, - activateTemplate, - createOrUpdateCustomPolicy, - refetchTemplates: fetchTemplates, - refetchSelectedTemplate, - }; -} diff --git a/src/app/(app)/settings/types/.ai-policies.md b/src/app/(app)/settings/types/.ai-policies.md deleted file mode 100644 index 6973452..0000000 --- a/src/app/(app)/settings/types/.ai-policies.md +++ /dev/null @@ -1,45 +0,0 @@ - -Configuration definitions and types for AI tool approval policies within the OpenFrame platform. - -## Key Components - -### Constants -- **`CUSTOM_CREATION_TEMPLATE_ID`** - Template identifier constant for custom policy creation - -### Interfaces -- **`PolicyTemplateSummary`** - Summary view of policy templates with basic metadata and override counts -- **`PolicyRule`** - Individual policy rule defining tool permissions and approval requirements -- **`PolicyTemplateDetail`** - Complete policy template with rules and custom overrides -- **`CustomPolicyRequest`** - Request structure for creating custom policies with approval level overrides - -## Usage Example - -```typescript -import { - PolicyTemplateSummary, - PolicyRule, - CUSTOM_CREATION_TEMPLATE_ID, - type CustomPolicyRequest -} from './ai-policies' - -// Create a custom policy request -const customPolicy: CustomPolicyRequest = { - templateId: 'security-template-001', - overrides: { - 'terminal.execute': 'MANAGER_APPROVAL', - 'file.delete': 'ADMIN_APPROVAL' - } -} - -// Define a policy rule for terminal access -const terminalRule: PolicyRule = { - tool: 'terminal', - function: 'execute', - policyGroup: 'system-access', - category: 'administrative', - operation: 'command-execution', - commandPattern: 'sudo.*', - approvalLevel: 'MANAGER_APPROVAL', - naturalKey: 'terminal-sudo-commands' -} -``` \ No newline at end of file diff --git a/src/app/(app)/settings/types/ai-settings.ts b/src/app/(app)/settings/types/ai-settings.ts deleted file mode 100644 index 0c1fc5d..0000000 --- a/src/app/(app)/settings/types/ai-settings.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { ApprovalLevel, PermissionCategory } from '@flamingo-stack/openframe-frontend-core'; -import type { PolicyTemplateDetail } from './ai-policies'; - -export type CustomPolicyState = { - enabled: boolean; - baseTemplateId: string | null; - originalRules: Map; - changes: Map; - existingOverrides: Record; - baseTemplateForDisplay: PolicyTemplateDetail | null; -}; - -export type EditSnapshot = { - templateId: string | null; - policyGroups: Map; - customBaseTemplateId: string | null; -}; diff --git a/src/app/(app)/settings/utils/ai-settings.utils.tsx b/src/app/(app)/settings/utils/ai-settings.utils.tsx deleted file mode 100644 index 810d088..0000000 --- a/src/app/(app)/settings/utils/ai-settings.utils.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import type { ApprovalLevel, PermissionCategory } from '@flamingo-stack/openframe-frontend-core'; -import { normalizeToolTypeWithFallback } from '@flamingo-stack/openframe-frontend-core/utils'; -import { Shield } from 'lucide-react'; -import type { ReactNode } from 'react'; -import type { PolicyRule } from '../types/ai-policies'; - -function slugify(value: string) { - return value - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, ''); -} - -export function buildPolicyGroups(rules: PolicyRule[]): Map { - const groupedByPolicyGroup = new Map< - string, - Map< - string, - { - id: string; - name: string; - icon: ReactNode; - policies: PermissionCategory['policies']; - } - > - >(); - - for (const rule of rules) { - const policyGroupName = rule.policyGroup || 'General'; - const categoryName = rule.category || 'Other'; - const categoryId = slugify(`${policyGroupName}:${categoryName}`) || 'other'; - - if (!groupedByPolicyGroup.has(policyGroupName)) { - groupedByPolicyGroup.set(policyGroupName, new Map()); - } - - const policyGroupMap = groupedByPolicyGroup.get(policyGroupName)!; - - if (!policyGroupMap.has(categoryId)) { - policyGroupMap.set(categoryId, { - id: categoryId, - name: categoryName, - icon: , - policies: [], - }); - } - - policyGroupMap.get(categoryId)!.policies.push({ - id: rule.naturalKey, - naturalKey: rule.naturalKey, - name: rule.operation || rule.naturalKey, - commandPattern: rule.commandPattern, - toolName: normalizeToolTypeWithFallback(rule.tool), - approvalLevel: rule.approvalLevel as ApprovalLevel, - }); - } - - const finalGroups = new Map(); - for (const [policyGroupName, categoriesMap] of groupedByPolicyGroup) { - const categories = Array.from(categoriesMap.values()) - .sort((a, b) => a.name.localeCompare(b.name)) - .map(c => ({ - id: c.id, - name: c.name, - icon: c.icon, - configurationsCount: c.policies.length, - globalPermission: undefined, - isExpanded: false, - policies: c.policies, - })) satisfies PermissionCategory[]; - - finalGroups.set(policyGroupName, categories); - } - - return finalGroups; -} - -export function clonePolicyGroups(groups: Map) { - return new Map( - Array.from(groups.entries()).map(([groupName, categories]) => [ - groupName, - categories.map(cat => ({ - ...cat, - policies: cat.policies.map(p => ({ ...p })), - })), - ]), - ); -} - -export function mapToObject(entries: Map) { - const obj: Record = {}; - entries.forEach((value, key) => { - obj[key] = value; - }); - return obj; -} diff --git a/src/lib/routes.ts b/src/lib/routes.ts index 87e316a..e723ecc 100644 --- a/src/lib/routes.ts +++ b/src/lib/routes.ts @@ -25,7 +25,15 @@ export const TAB_IDS = { customersList: ['active', 'archived'], - customerDetails: ['devices', 'tickets', 'logs', 'worktime', 'details', 'custom-ai-assistant'], + customerDetails: [ + 'devices', + 'tickets', + 'logs', + 'worktime', + 'details', + 'custom-ai-assistant', + 'customer-ai-guardrails', + ], deviceDetails: [ 'overview', 'vulnerabilities', From 43ba789b818ec8d36708c6305a3aed31904003b6 Mon Sep 17 00:00:00 2001 From: Oleksandr Koltunov Date: Fri, 17 Jul 2026 17:45:19 +0300 Subject: [PATCH 3/5] fix(): native shell auth --- docs/static-export-migration.md | 12 ++++-- package-lock.json | 8 ++-- package.json | 2 +- src/app/(app)/help-center/endpoints.ts | 35 ++++++++++++---- .../auth/components/dev-ticket-observer.tsx | 2 + .../openframe-chat-runtime-provider.tsx | 30 ++++++++++---- src/lib/auth-api-client.ts | 18 +++++++- src/lib/native-login.ts | 41 +++++++++++++------ src/lib/native-shell.ts | 22 +++++++++- 9 files changed, 126 insertions(+), 44 deletions(-) diff --git a/docs/static-export-migration.md b/docs/static-export-migration.md index 861080e..71c00e7 100644 --- a/docs/static-export-migration.md +++ b/docs/static-export-migration.md @@ -89,10 +89,14 @@ build is green; the bundle is not yet runtime-functional from a `localhost` orig `http://tauri.localhost` (Tauri on Windows) origins, and the shell injecting the token from Face-ID-gated secure storage. -- [ ] **C. `/content/*` embedded-chat proxy — localized** - `src/app/components/openframe-chat-runtime-provider.tsx` relies on the `rewrites()` - same-origin proxy (omitted under export). Switch to absolute gateway URLs + Bearer - + CORS (the provider already has cross-origin/Bearer logic). +- [x] **C. `/content/*` embedded-chat proxy — localized** *(frontend side done + 2026-07-17; gateway CORS for `/content/*` from the shell origins still item B)* + In the native shell, `help-center/endpoints.ts` (`CONTENT_ORIGIN`) and the chat + runtime provider absolutize every `/content` URL to the tenant gateway, and the + chat `EmbedAuthAdapter` sanctions that origin via the core lib's new + `allowedOrigins` field on `embedAuthedFetch`'s cross-origin guard + (openframe-frontend-core `embed-authed-fetch.ts`). Web builds keep the + relative same-origin base + `rewrites()`/reverse-proxy path unchanged. - [ ] **D. Native-shell SPA fallback** so cold loads of non-prerendered paths resolve to the app shell client-side: the help-center CMS routes (placeholder-only), and any diff --git a/package-lock.json b/package-lock.json index 1b8b62c..80752ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "openframe-frontend", "version": "0.1.0", "dependencies": { - "@flamingo-stack/openframe-frontend-core": "^0.0.442", + "@flamingo-stack/openframe-frontend-core": "^0.0.443", "@hookform/resolvers": "^5.2.2", "@monaco-editor/react": "^4.7.0", "@tanstack/react-query": "^5.90.16", @@ -498,9 +498,9 @@ } }, "node_modules/@flamingo-stack/openframe-frontend-core": { - "version": "0.0.442", - "resolved": "https://registry.npmjs.org/@flamingo-stack/openframe-frontend-core/-/openframe-frontend-core-0.0.442.tgz", - "integrity": "sha512-mCubV7ItIgDQO/bW581y4OYpV8kheM5opz79JZQxKoHS7/t9BS3T0z5zLktL/T5uO9UNgkhMVErNpjWgow8pkA==", + "version": "0.0.443", + "resolved": "https://registry.npmjs.org/@flamingo-stack/openframe-frontend-core/-/openframe-frontend-core-0.0.443.tgz", + "integrity": "sha512-K8N9waIqbVcz9uObxT70YYXNvEwo1y1oc01jN+vqIePcElh9O5G11LP5ZMQAPPDQLyYdogj1y2fso6Hy+UnaJg==", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", diff --git a/package.json b/package.json index 223f538..6d0416d 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "core:unlink": "yalc remove @flamingo-stack/openframe-frontend-core" }, "dependencies": { - "@flamingo-stack/openframe-frontend-core": "^0.0.442", + "@flamingo-stack/openframe-frontend-core": "^0.0.443", "@hookform/resolvers": "^5.2.2", "@monaco-editor/react": "^4.7.0", "@tanstack/react-query": "^5.90.16", diff --git a/src/app/(app)/help-center/endpoints.ts b/src/app/(app)/help-center/endpoints.ts index e9433c7..d7d0046 100644 --- a/src/app/(app)/help-center/endpoints.ts +++ b/src/app/(app)/help-center/endpoints.ts @@ -2,14 +2,25 @@ * SINGLE SOURCE for every Help Center content endpoint — every page reads from * `EP`, so repointing the data source is a one-line change to `CONTENT_BASE`. * - * `CONTENT_BASE` is RELATIVE (`/content`) — same-origin, exactly like the - * embedded chat. The browser always calls the page origin; Next's `rewrites()` - * (see `next.config.mjs`) forwards `/content/*` → `${NEXT_PUBLIC_TENANT_HOST_URL}/content/*` - * SERVER-SIDE, carrying the session cookie + auth headers — the SAME proven path - * the chat uses. This is deliberately NOT an absolute cross-origin URL: a direct - * browser → tenant-host request doesn't reliably carry the session (cross-origin - * cookie) and forces the dev-ticket localStorage bearer + a cross-origin refresh, - * which is exactly what 401s. Staying same-origin avoids all of that. + * On the WEB, `CONTENT_BASE` is RELATIVE (`/content`) — same-origin, exactly + * like the embedded chat. The browser always calls the page origin; Next's + * `rewrites()` (see `next.config.mjs`) forwards `/content/*` → + * `${NEXT_PUBLIC_TENANT_HOST_URL}/content/*` SERVER-SIDE, carrying the session + * cookie + auth headers — the SAME proven path the chat uses. This is + * deliberately NOT an absolute cross-origin URL: a direct browser → tenant-host + * request doesn't reliably carry the session (cross-origin cookie) and forces + * the dev-ticket localStorage bearer + a cross-origin refresh, which is exactly + * what 401s. Staying same-origin avoids all of that. + * + * In the NATIVE SHELL the page origin is `capacitor://localhost` (static + * export, no Next server, no `rewrites()`), so a relative `/content` resolves + * to a file that doesn't exist in the bundle. There — and ONLY there — + * `CONTENT_ORIGIN` absolutizes every content endpoint to the tenant gateway. + * The shell is always in bearer mode, and the chat's `EmbedAuthAdapter` lists + * this origin in `allowedOrigins` so `embedAuthedFetch`'s cross-origin guard + * sanctions it. Module-load evaluation is safe: a login that CHANGES the + * tenant host does a full `window.location.assign` (see `NativeLoginResult. + * tenantHostChanged`), so this module re-evaluates with the new host. * * The lib's content surfaces still fetch through `contentFetch`, which — because * this app registers an EmbedAuthAdapter for the chat — routes through @@ -17,8 +28,14 @@ * 401-refresh). Same-origin keeps `embedAuthedFetch` valid in prod builds too. */ import type { EndpointsRuntime } from '@flamingo-stack/openframe-frontend-core/contexts'; +import { isNativeShell } from '@/lib/native-shell'; +import { runtimeEnv } from '@/lib/runtime-config'; + +/** `''` on the web (relative, same-origin); the tenant gateway origin in the + * native shell. Exported for the chat auth adapter's `allowedOrigins`. */ +export const CONTENT_ORIGIN = isNativeShell() ? runtimeEnv.tenantHostUrl() : ''; -const CONTENT_BASE = '/content'; +const CONTENT_BASE = `${CONTENT_ORIGIN}/content`; const CONTENT = `${CONTENT_BASE}/api`; /** diff --git a/src/app/(auth)/auth/components/dev-ticket-observer.tsx b/src/app/(auth)/auth/components/dev-ticket-observer.tsx index 83ca657..0b7db41 100644 --- a/src/app/(auth)/auth/components/dev-ticket-observer.tsx +++ b/src/app/(auth)/auth/components/dev-ticket-observer.tsx @@ -11,6 +11,8 @@ import { runtimeEnv } from '@/lib/runtime-config'; * * Monitors the URL for devTicket search parameter across the entire application. * When detected, it triggers the exchange process via dedicated hooks. + * (Mobile-app logins never land here: authMobile=true makes the gateway 302 + * the ticket straight to the app's custom scheme — see native-login.ts.) * * Enable/disable via NEXT_PUBLIC_ENABLE_DEV_TICKET_OBSERVER environment variable */ diff --git a/src/app/components/openframe-chat-runtime-provider.tsx b/src/app/components/openframe-chat-runtime-provider.tsx index 5ab18f3..d852779 100644 --- a/src/app/components/openframe-chat-runtime-provider.tsx +++ b/src/app/components/openframe-chat-runtime-provider.tsx @@ -48,6 +48,7 @@ import { } from '@flamingo-stack/openframe-frontend-core/utils'; import { useRouter } from 'next/navigation'; import { type ReactNode, useCallback, useMemo } from 'react'; +import { CONTENT_ORIGIN } from '@/app/(app)/help-center/endpoints'; import { composeOpenframeChatContentUrl } from '@/app/(app)/help-center/help-center-content-href'; import { getAccessTokenSync, isBearerAuthMode } from '@/lib/token-store'; @@ -100,6 +101,12 @@ const CHAT_AUTH_ADAPTER: EmbedAuthAdapter = { // `embedAuthedFetch` dedups 401-triggered retries on top — so a stampede of // simultaneously-expiring chat requests refreshes once. refresh: () => refreshAccessToken(), + // Native shell only: the page origin (`capacitor://localhost`) has no server + // behind it, so every `/content` call goes ABSOLUTE to the tenant gateway + // (see `help-center/endpoints.ts`) — sanction exactly that origin for + // `embedAuthedFetch`'s cross-origin guard. Empty on the web, where the + // same-origin rule stays absolute. + allowedOrigins: CONTENT_ORIGIN ? [CONTENT_ORIGIN] : [], }; // Register the auth adapter + drop legacy proxy-auth ONCE, at module load — @@ -165,14 +172,19 @@ export function OpenframeChatRuntimeProvider({ children }: { children: ReactNode ); const runtime = useMemo(() => { - // Guide-mode endpoints are SAME-ORIGIN relative `/content/*` paths. The - // lib's `embedAuthedFetch` rejects cross-origin URLs in production builds - // (bearer + cookies must not leak across origins); keeping these relative - // means the browser always calls the page origin and the guard passes in - // every build. The Next.js `rewrites()` (see `next.config.mjs`) forwards - // `/content/*` to the tenant gateway, and in a same-origin production - // deployment the platform reverse proxy answers it before Next does. - const content = (path: string): string => `/content${path}`; + // On the WEB, Guide-mode endpoints are SAME-ORIGIN relative `/content/*` + // paths. The lib's `embedAuthedFetch` rejects cross-origin URLs in + // production builds (bearer + cookies must not leak across origins); + // keeping these relative means the browser always calls the page origin + // and the guard passes in every build. The Next.js `rewrites()` (see + // `next.config.mjs`) forwards `/content/*` to the tenant gateway, and in + // a same-origin production deployment the platform reverse proxy answers + // it before Next does. In the NATIVE SHELL neither exists (static export + // on `capacitor://localhost`), so `CONTENT_ORIGIN` absolutizes the base + // to the tenant gateway — the origin `CHAT_AUTH_ADAPTER.allowedOrigins` + // sanctions for the guard. Same split as `help-center/endpoints.ts`. + const contentBase = `${CONTENT_ORIGIN}/content`; + const content = (path: string): string => `${contentBase}${path}`; return { endpoints: { @@ -213,7 +225,7 @@ export function OpenframeChatRuntimeProvider({ children }: { children: ReactNode // `/content` reverse proxy so the URLs land on MPH. Returning null // here (the old TODO) left every such card with no URL → no fetch → // blank card. - buildListUrl: (type, ids) => buildEntityCardListUrl(type, ids, '/content'), + buildListUrl: (type, ids) => buildEntityCardListUrl(type, ids, contentBase), attachmentUploadUrl: content('/api/storage/generate-upload-url'), attachmentViewUrlPrefix: content('/api/storage/view/chat-attachments/'), // Identity endpoint = the MPH source route `app/api/auth/identity/route.ts` diff --git a/src/lib/auth-api-client.ts b/src/lib/auth-api-client.ts index c9fb882..7d9a1b6 100644 --- a/src/lib/auth-api-client.ts +++ b/src/lib/auth-api-client.ts @@ -53,6 +53,14 @@ class AuthApiClient { headers: Record, init: RequestInit, ): Promise | null> { + // Mirror api-client: on the auth pages a 401 just means "not signed in + // yet" — never refresh-or-force-logout from here. A stale 401 chain that + // resolved after nativeLogin() stored fresh tokens used to forceLogout and + // wipe the Keychain right after a successful mobile login. + if (typeof window !== 'undefined' && window.location.pathname.startsWith('/auth')) { + return null; + } + const refreshSuccess = await refreshAccessToken(); if (refreshSuccess) { @@ -239,10 +247,16 @@ class AuthApiClient { }); } - loginUrl(tenantId: string, redirectTo: string, provider?: string) { + loginUrl(tenantId: string, redirectTo: string, provider?: string, options?: { authMobile?: boolean }) { const providerParam = provider && provider !== 'openframe-sso' ? `&provider=${encodeURIComponent(provider)}` : ''; const base = `/oauth/login?tenantId=${encodeURIComponent(tenantId)}${providerParam}`; - const path = isSaasSharedMode() ? base : `${base}&redirectTo=${redirectTo}`; + // authMobile logins always carry redirectTo (the app's custom scheme) — + // the gateway 302s the devTicket straight to the app, in shared mode too. + const path = options?.authMobile + ? `${base}&authMobile=true&redirectTo=${redirectTo}` + : isSaasSharedMode() + ? base + : `${base}&redirectTo=${redirectTo}`; return buildAuthUrl(path); } diff --git a/src/lib/native-login.ts b/src/lib/native-login.ts index a239afb..8a15bfd 100644 --- a/src/lib/native-login.ts +++ b/src/lib/native-login.ts @@ -1,12 +1,15 @@ /** - * Native-shell login: runs the gateway BFF OAuth flow in a system browser - * sheet (ASWebAuthenticationSession), receives the dev-ticket on an https - * callback the session intercepts, exchanges it natively, and puts the tokens - * in the Keychain. Prototype flow — requires `dev-ticket-enabled` on the - * gateway; not for production tenants. + * Native-shell login: runs the gateway BFF OAuth flow in a shell-owned browser + * context, receives the dev-ticket on the callback, exchanges it natively, and + * puts the tokens in the Keychain. On mobile the browser is an + * ASWebAuthenticationSession completing on the app's custom scheme (Google + * blocks OAuth in embedded webviews — 403 disallowed_useragent); the gateway + * 302s the devTicket straight to that scheme for authMobile=true logins. The + * desktop shell intercepts the https callback directly. Prototype flow — + * requires `dev-ticket-enabled` on the gateway; not for production tenants. */ import { authApiClient } from './auth-api-client'; -import { nativeAuthPlugin, storeTenantHost } from './native-shell'; +import { MOBILE_APP_SCHEME, nativeAuthPlugin, nativePlatform, storeTenantHost } from './native-shell'; import { runtimeEnv } from './runtime-config'; import { setTokens } from './token-store'; @@ -46,19 +49,27 @@ export async function nativeLogin(options: { ); } - // The BFF only accepts http(s) redirect targets, so the callback is an https - // URL on the tenant host; the auth session intercepts it before navigation. - const callbackUrl = `${tenantHost}${CALLBACK_PATH}`; - const rawLoginUrl = authApiClient.loginUrl(options.tenantId, encodeURIComponent(callbackUrl), options.provider); + const isMobileShell = nativePlatform() !== null; + + // Mobile (authMobile=true): the gateway 302s the devTicket straight to the + // app's custom scheme — the auth session completes on it, no https landing. + // Desktop: the BFF only accepts http(s) redirect targets there; the shell + // window intercepts the tenant-host callback before navigation. + const redirectTarget = isMobileShell ? `${MOBILE_APP_SCHEME}://auth` : `${tenantHost}${CALLBACK_PATH}`; + const rawLoginUrl = authApiClient.loginUrl(options.tenantId, encodeURIComponent(redirectTarget), options.provider, { + authMobile: isMobileShell, + }); const loginUrl = rawLoginUrl.startsWith('http') ? rawLoginUrl : `${tenantHost}${rawLoginUrl}`; const { callbackUrl: resultUrl } = await plugin.start({ url: loginUrl, - callbackHost: new URL(callbackUrl).hostname, + callbackHost: new URL(tenantHost).hostname, callbackPath: CALLBACK_PATH, + ...(isMobileShell ? { callbackScheme: MOBILE_APP_SCHEME } : {}), }); - const ticket = new URL(resultUrl).searchParams.get('devTicket'); + const parsedResult = new URL(resultUrl); + const ticket = parsedResult.searchParams.get('devTicket'); if (!ticket) { throw new Error('Login completed without a ticket — is dev-ticket enabled on the gateway?'); } @@ -74,7 +85,11 @@ export async function nativeLogin(options: { await setTokens({ accessToken, refreshToken }); - const learnedHost = new URL(resultUrl).origin; + // https callback (desktop): the origin is TLS-authenticated, take it as-is. + // Scheme callback (mobile) carries no host — the discovery-resolved tenant + // host is the gateway (the backend guarantees discovery `domain` is the + // exact canonical tenant host). + const learnedHost = parsedResult.protocol === 'https:' ? parsedResult.origin : new URL(tenantHost).origin; storeTenantHost(learnedHost); // Also persist it shell-side: the shell refreshes tokens (and later runs // background NATS) with its own networking, which must not depend on diff --git a/src/lib/native-shell.ts b/src/lib/native-shell.ts index 536d9da..3b52175 100644 --- a/src/lib/native-shell.ts +++ b/src/lib/native-shell.ts @@ -4,9 +4,27 @@ * no Capacitor npm dependency, so all bridge access goes through these helpers. */ +/** + * Custom URL scheme the mobile app registers (CFBundleURLTypes). The login + * ASWebAuthenticationSession completes when navigation hits it; the gateway + * 302s the devTicket straight to it for authMobile=true logins. + */ +export const MOBILE_APP_SCHEME = 'com.openframe.app'; + export interface NativeAuthPlugin { - /** Runs the OAuth login in an ASWebAuthenticationSession; resolves with the final callback URL. */ - start(options: { url: string; callbackHost: string; callbackPath: string }): Promise<{ callbackUrl: string }>; + /** + * Runs the OAuth login in a shell-owned browser context and resolves with + * the final callback URL. Mobile shells run a system browser + * (ASWebAuthenticationSession) that completes on `callbackScheme`; the + * desktop shell runs a dedicated window that intercepts the https + * callbackHost/callbackPath landing and ignores `callbackScheme`. + */ + start(options: { + url: string; + callbackHost: string; + callbackPath: string; + callbackScheme?: string; + }): Promise<{ callbackUrl: string }>; /** Performs the dev-ticket exchange over native HTTP (no CORS) and returns tokens from response headers. */ exchangeTicket(options: { url: string }): Promise<{ accessToken?: string; refreshToken?: string }>; getTokens(): Promise<{ accessToken?: string; refreshToken?: string }>; From 5373d981893ecc6b6bb83b4a2f4ff405a056a291 Mon Sep 17 00:00:00 2001 From: Oleksandr Koltunov Date: Fri, 17 Jul 2026 20:41:49 +0300 Subject: [PATCH 4/5] feat(): customer ai settings & guardrails --- package.json | 2 +- .../customer-ai-assistant-appearance.tsx | 281 ------------- .../customer-appearance.types.ts | 23 -- .../customer-ai-configuration.tsx | 362 +++++++++++++++++ .../use-customer-ai-configuration-form.ts} | 60 ++- .../components/customer-details-view.tsx | 31 +- .../customer-guardrails-settings.tsx | 372 ++++++++++++++++++ .../customer-custom-ai-assistant-tab.tsx | 94 ++--- .../details-tabs/customer-guardrails-tab.tsx | 108 +++-- .../components/details-tabs/customer-tabs.tsx | 10 +- .../components/new-customer-page.tsx | 275 ++++++++----- .../guardrails/guardrails-panel.tsx | 53 +-- .../guardrails/guardrails-policy-groups.tsx | 37 ++ .../guardrails/guardrails-preset-card.tsx | 21 + .../guardrails/guardrails-template-picker.tsx | 66 +++- .../components/guardrails/guardrails.types.ts | 12 +- .../guardrails/use-guardrails-editor.ts | 34 +- .../guardrails/use-organization-guardrails.ts | 218 ++++++++++ .../hooks/use-organization-ai-config.ts | 176 +++++++++ .../ai-settings/hooks/use-supported-models.ts | 17 + .../hooks/use-update-ai-configuration.ts | 12 +- .../components/ticket-details-view.tsx | 57 ++- .../(app)/tickets/queries/dialogs-queries.ts | 6 + src/app/(app)/tickets/types/dialog.types.ts | 6 + .../tickets/utils/latest-assistant-model.ts | 34 ++ src/app/hooks/use-ai-model.ts | 58 +-- src/lib/feature-flags.ts | 6 + src/lib/routes.ts | 4 +- 28 files changed, 1794 insertions(+), 641 deletions(-) delete mode 100644 src/app/(app)/customers/components/ai-assistant-appearance/customer-ai-assistant-appearance.tsx delete mode 100644 src/app/(app)/customers/components/ai-assistant-appearance/customer-appearance.types.ts create mode 100644 src/app/(app)/customers/components/customer-ai-configuration/customer-ai-configuration.tsx rename src/app/(app)/customers/components/{ai-assistant-appearance/use-customer-appearance-form.ts => customer-ai-configuration/use-customer-ai-configuration-form.ts} (55%) create mode 100644 src/app/(app)/customers/components/customer-guardrails-settings.tsx create mode 100644 src/app/(app)/settings/ai-settings/components/guardrails/guardrails-policy-groups.tsx create mode 100644 src/app/(app)/settings/ai-settings/components/guardrails/guardrails-preset-card.tsx create mode 100644 src/app/(app)/settings/ai-settings/components/guardrails/use-organization-guardrails.ts create mode 100644 src/app/(app)/settings/ai-settings/hooks/use-organization-ai-config.ts create mode 100644 src/app/(app)/tickets/utils/latest-assistant-model.ts diff --git a/package.json b/package.json index 6d0416d..455045a 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "core:unlink": "yalc remove @flamingo-stack/openframe-frontend-core" }, "dependencies": { - "@flamingo-stack/openframe-frontend-core": "^0.0.443", + "@flamingo-stack/openframe-frontend-core": "file:.yalc/@flamingo-stack/openframe-frontend-core", "@hookform/resolvers": "^5.2.2", "@monaco-editor/react": "^4.7.0", "@tanstack/react-query": "^5.90.16", diff --git a/src/app/(app)/customers/components/ai-assistant-appearance/customer-ai-assistant-appearance.tsx b/src/app/(app)/customers/components/ai-assistant-appearance/customer-ai-assistant-appearance.tsx deleted file mode 100644 index 17f26ab..0000000 --- a/src/app/(app)/customers/components/ai-assistant-appearance/customer-ai-assistant-appearance.tsx +++ /dev/null @@ -1,281 +0,0 @@ -'use client'; - -import { - MonitorIcon, - MoonStarIcon, - PenEditIcon, - Sun01Icon, -} from '@flamingo-stack/openframe-frontend-core/components/icons-v2'; -import { - Button, - CheckboxBlock, - ColorPickerInput, - ImageUploader, - Input, - Skeleton, - TabSelector, -} from '@flamingo-stack/openframe-frontend-core/components/ui'; -import { useToast } from '@flamingo-stack/openframe-frontend-core/hooks'; -import { useQueryClient } from '@tanstack/react-query'; -import { useRouter } from 'next/navigation'; -import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react'; -import { Controller } from 'react-hook-form'; -import { AiSettingsPreviews } from '@/app/(app)/settings/ai-settings/components/previews/ai-settings-previews'; -import { - clientViewQueryKeys, - useClientView, - useResetClientView, - useUpdateClientView, -} from '@/app/(app)/settings/ai-settings/hooks/use-client-view'; -import { getDefaultClientView } from '@/app/(app)/settings/ai-settings/types/ai-settings'; -import { ConfirmDialog } from '@/app/components/shared/confirm-dialog'; -import { getFullImageUrl } from '@/lib/image-url'; -import { routes } from '@/lib/routes'; -import { useCustomerAppearanceForm } from './use-customer-appearance-form'; - -interface CustomerAiAssistantAppearanceProps { - /** Organization the appearance is scoped to (edit mode only). */ - organizationId: string; -} - -/** Imperative API the parent ("Save Customer") drives to persist this block. */ -export interface CustomerAppearanceHandle { - /** Validates the custom fields. Resolves false when the user must fix them first. */ - validate: () => Promise; - /** Persists the appearance: updates the override, or resets it when "use default" is on. */ - commit: () => Promise; -} - -/** - * "AI-Assistant Appearance" block on the customer edit page. Mirrors the - * settings/ai-settings client appearance, but scopes every read/write to a - * specific `organizationId` instead of the tenant-wide default (null). - * - * It owns no Save button — the page's "Save Customer" drives persistence via the - * `validate()` / `commit()` ref handle. "Use the default AI-Assistant appearance" - * on means the customer inherits the tenant default (an existing override is - * deleted immediately once the user confirms); off edits a per-customer override. - */ -export const CustomerAiAssistantAppearance = forwardRef( - function CustomerAiAssistantAppearance({ organizationId }, ref) { - const router = useRouter(); - const queryClient = useQueryClient(); - // Org-scoped override (null when the customer inherits the default). - const { view: orgView, isLoading } = useClientView(organizationId); - // Tenant-wide default, used for the "use default" previews. - const { view: defaultView } = useClientView(null); - // Opt out of auto-invalidation: commit() refetches once after the avatar - // upload, so the preview never flickers the pre-upload image. - const { update } = useUpdateClientView(organizationId, { invalidateOnSuccess: false }); - const { reset, isPending: isResetting } = useResetClientView(organizationId); - const { toast } = useToast(); - - const [useDefault, setUseDefault] = useState(true); - const [confirmResetOpen, setConfirmResetOpen] = useState(false); - - // Seed the toggle once the org record has loaded: an existing override starts - // in custom mode, otherwise we default to "use default". - const seededRef = useRef(false); - useEffect(() => { - if (seededRef.current || isLoading) return; - seededRef.current = true; - setUseDefault(!orgView); - }, [isLoading, orgView]); - - const effectiveView = orgView ?? defaultView ?? getDefaultClientView(organizationId); - const fallbackDefault = defaultView ?? getDefaultClientView(null); - - const { form, avatarUrl, handleAvatarChange, handleAvatarRemove, commitAvatar } = useCustomerAppearanceForm({ - view: effectiveView, - }); - - const assistantName = form.watch('assistantName'); - const applicationTheme = form.watch('applicationTheme'); - const accentColor = form.watch('accentColor'); - - // Persistence is driven by the page's "Save Customer" button. - useImperativeHandle( - ref, - () => ({ - validate: () => (useDefault ? Promise.resolve(true) : form.trigger()), - commit: async () => { - if (useDefault) { - // Drop the override only if one exists; otherwise nothing to do. - if (orgView) await reset(); - return; - } - const values = form.getValues(); - - const savedView = await update({ - assistantName: values.assistantName, - applicationTheme: values.applicationTheme, - accentColor: values.accentColor, - }); - const clientViewId = savedView?.id ?? orgView?.id; - if (clientViewId) await commitAvatar(clientViewId); - // Single refetch after the avatar lands: the view and its avatar live - // in separate stores, so this is the one point where both are current. - // (Also keeps the cache fresh for an SPA revisit — no hard refresh.) - await queryClient.invalidateQueries({ queryKey: clientViewQueryKeys.detail(organizationId) }); - }, - }), - [useDefault, organizationId, orgView, form, update, reset, commitAvatar, queryClient], - ); - - const handleToggle = (checked: boolean) => { - if (!checked) { - // Switching to custom mode. - setUseDefault(false); - return; - } - // Switching to default: confirm first; the override is deleted on confirm. - if (orgView) { - setConfirmResetOpen(true); - return; - } - setUseDefault(true); - }; - - const header = ( -
-

AI-Assistant Appearance

- -
- ); - - const toggle = ( - handleToggle(Boolean(checked))} - /> - ); - - if (isLoading) { - return ( -
- {header} - {toggle} - -
- ); - } - - return ( -
- {header} - {toggle} - - {useDefault ? ( - - ) : ( - <> -
-
- ( - - )} - /> - - ( - }, - { id: 'LIGHT', label: 'Light', icon: }, - { id: 'SYSTEM', label: 'System', icon: }, - ]} - /> - )} - /> - -
-

Custom Accent Color

- } - /> -
-
- -
- -
-
- - - - )} - - { - try { - await reset(); - setUseDefault(true); - setConfirmResetOpen(false); - toast({ - title: 'Saved', - description: 'Customer now uses the default AI-Assistant appearance', - variant: 'success', - }); - } catch (err) { - toast({ - title: 'Save failed', - description: err instanceof Error ? err.message : 'Failed to remove the custom appearance', - variant: 'destructive', - }); - } - }} - /> -
- ); - }, -); diff --git a/src/app/(app)/customers/components/ai-assistant-appearance/customer-appearance.types.ts b/src/app/(app)/customers/components/ai-assistant-appearance/customer-appearance.types.ts deleted file mode 100644 index fdad17b..0000000 --- a/src/app/(app)/customers/components/ai-assistant-appearance/customer-appearance.types.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { z } from 'zod'; -import type { ClientView } from '@/app/(app)/settings/ai-settings/types/ai-settings'; - -/** - * Per-customer appearance edits only the ClientView (name, avatar, theme, - * accent) — the AI logic config (provider/model/quick actions) is tenant-wide - * per agent and is not overridable per organization. - */ -export const customerAppearanceSchema = z.object({ - assistantName: z.string().trim().min(1, 'Assistant name is required'), - applicationTheme: z.enum(['DARK', 'LIGHT', 'SYSTEM']), - accentColor: z.string().regex(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/, 'Enter a valid hex color (e.g. #F357BB)'), -}); - -export type CustomerAppearanceFormValues = z.infer; - -export function getCustomerAppearanceDefaults(view: ClientView): CustomerAppearanceFormValues { - return { - assistantName: view.assistantName, - applicationTheme: view.applicationTheme, - accentColor: view.accentColor, - }; -} diff --git a/src/app/(app)/customers/components/customer-ai-configuration/customer-ai-configuration.tsx b/src/app/(app)/customers/components/customer-ai-configuration/customer-ai-configuration.tsx new file mode 100644 index 0000000..0a51c43 --- /dev/null +++ b/src/app/(app)/customers/components/customer-ai-configuration/customer-ai-configuration.tsx @@ -0,0 +1,362 @@ +'use client'; + +import { + MonitorIcon, + MoonStarIcon, + PenEditIcon, + Sun01Icon, +} from '@flamingo-stack/openframe-frontend-core/components/icons-v2'; +import { + Button, + CheckboxBlock, + ColorPickerInput, + ImageUploader, + Input, + LoadError, + Skeleton, + TabSelector, +} from '@flamingo-stack/openframe-frontend-core/components/ui'; +import { useToast } from '@flamingo-stack/openframe-frontend-core/hooks'; +import { useQueryClient } from '@tanstack/react-query'; +import { useRouter } from 'next/navigation'; +import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react'; +import { Controller } from 'react-hook-form'; +import { + AiAnswerStyleFields, + AiProviderModelFields, +} from '@/app/(app)/settings/ai-settings/components/ai-config-fields'; +import { ASSISTANT_QUICK_ACTIONS_CONFIG } from '@/app/(app)/settings/ai-settings/components/ai-settings-quick-actions'; +import { AiSettingsQuickActionsEditor } from '@/app/(app)/settings/ai-settings/components/ai-settings-quick-actions-editor'; +import { AiSettingsPreviews } from '@/app/(app)/settings/ai-settings/components/previews/ai-settings-previews'; +import { + clientViewQueryKeys, + useClientView, + useResetClientView, + useUpdateClientView, +} from '@/app/(app)/settings/ai-settings/hooks/use-client-view'; +import { useHubDefaultQuickActions } from '@/app/(app)/settings/ai-settings/hooks/use-hub-default-quick-actions'; +import { + useOrganizationClientAiConfig, + useResetOrganizationClientAiConfig, + useUpdateOrganizationClientAiConfig, +} from '@/app/(app)/settings/ai-settings/hooks/use-organization-ai-config'; +import { getProviderModelLabel, useSupportedModels } from '@/app/(app)/settings/ai-settings/hooks/use-supported-models'; +import { toAgentAiConfigInput } from '@/app/(app)/settings/ai-settings/types/ai-logic.types'; +import { getDefaultClientView } from '@/app/(app)/settings/ai-settings/types/ai-settings'; +import { ConfirmDialog } from '@/app/components/shared/confirm-dialog'; +import { getFullImageUrl } from '@/lib/image-url'; +import { routes } from '@/lib/routes'; +import { useCustomerAiConfigurationForm } from './use-customer-ai-configuration-form'; + +/** Imperative API the parent ("Save Customer") drives to persist this block. */ +export interface CustomerAiConfigurationHandle { + /** Validates the custom fields. Resolves false when the user must fix them first. */ + validate: () => Promise; + /** Persists the configuration: updates both overrides, or nothing when inheriting. */ + commit: () => Promise; +} + +interface CustomerAiConfigurationProps { + /** Organization the configuration is scoped to (edit mode only). */ + organizationId: string; +} + +/** + * "Customer AI Configuration" tab on the customer edit page. Reuses the global + * CLIENT screen's schema and field components, but scopes everything to one + * organization across the two backend overrides: ClientView (name/avatar/ + * theme/accent) and OrganizationClientAiConfig (provider/model/answer style/ + * quick actions). One "use default" toggle governs both — checking it resets + * both overrides (after confirm); unchecked, "Save Customer" persists both via + * the `validate()` / `commit()` ref handle. + */ +export const CustomerAiConfiguration = forwardRef( + function CustomerAiConfiguration({ organizationId }, ref) { + const router = useRouter(); + const queryClient = useQueryClient(); + const { toast } = useToast(); + + // Org-scoped overrides (view is null / config inherits when not overridden). + const { view: orgView, isLoading: isViewLoading } = useClientView(organizationId); + const { + config: orgConfig, + isLoading: isConfigLoading, + error: configError, + refetch: refetchConfig, + } = useOrganizationClientAiConfig(organizationId); + // Tenant-wide default appearance: previews while inheriting. + const { view: defaultView } = useClientView(null); + + const { update: updateView } = useUpdateClientView(organizationId, { invalidateOnSuccess: false }); + const { reset: resetView, isPending: isResettingView } = useResetClientView(organizationId); + const { update: updateAiConfig } = useUpdateOrganizationClientAiConfig(organizationId); + const { reset: resetAiConfig, isPending: isResettingAi } = useResetOrganizationClientAiConfig(organizationId); + const { modelsByProvider } = useSupportedModels(); + + const [useDefault, setUseDefault] = useState(true); + const [confirmResetOpen, setConfirmResetOpen] = useState(false); + + // OpenFrame default quick actions from the Product Hub (the BE stores only + // customs); the editor shows them as the dimmed preview / uncheck seed. + const hubDefaults = useHubDefaultQuickActions(ASSISTANT_QUICK_ACTIONS_CONFIG.agentSlug, { + enabled: !useDefault, + }); + + const hasAiOverride = !!orgConfig && !orgConfig.inheritDefault; + const hasAnyOverride = !!orgView || hasAiOverride; + + // Seed the toggle once both org records have loaded: any existing override + // starts in custom mode, otherwise the customer inherits the defaults. + const seededRef = useRef(false); + useEffect(() => { + if (seededRef.current || isViewLoading || isConfigLoading || !orgConfig) return; + seededRef.current = true; + setUseDefault(!orgView && orgConfig.inheritDefault); + }, [isViewLoading, isConfigLoading, orgView, orgConfig]); + + const effectiveView = orgView ?? defaultView ?? getDefaultClientView(organizationId); + const fallbackDefault = defaultView ?? getDefaultClientView(null); + + const { form, avatarUrl, handleAvatarChange, handleAvatarRemove, commitAvatar } = useCustomerAiConfigurationForm({ + view: effectiveView, + config: orgConfig, + }); + + const assistantName = form.watch('assistantName'); + const applicationTheme = form.watch('applicationTheme'); + const accentColor = form.watch('accentColor'); + const llmProvider = form.watch('llmProvider'); + const providerModel = form.watch('providerModel'); + const answerStyle = form.watch('answerStyle'); + + // Persistence is driven by the page's "Save Customer" button. + useImperativeHandle( + ref, + () => ({ + validate: () => (useDefault ? Promise.resolve(true) : form.trigger()), + commit: async () => { + if (useDefault) { + // Overrides are dropped when the user confirms the toggle; this + // covers the case where one still exists at save time. + if (orgView) await resetView(); + if (hasAiOverride) await resetAiConfig(); + return; + } + const values = form.getValues(); + + const savedView = await updateView({ + assistantName: values.assistantName, + applicationTheme: values.applicationTheme, + accentColor: values.accentColor, + }); + const clientViewId = savedView?.id ?? orgView?.id; + if (clientViewId) await commitAvatar(clientViewId); + + await updateAiConfig(toAgentAiConfigInput(values)); + + // Single refetch after the avatar lands — the view and its avatar + // live in separate stores, so this is where both are current. + await queryClient.invalidateQueries({ queryKey: clientViewQueryKeys.detail(organizationId) }); + }, + }), + [ + useDefault, + organizationId, + orgView, + hasAiOverride, + form, + updateView, + updateAiConfig, + resetView, + resetAiConfig, + commitAvatar, + queryClient, + ], + ); + + const handleToggle = (checked: boolean) => { + if (!checked) { + setUseDefault(false); + return; + } + // Switching back to defaults: confirm first; overrides are reset on confirm. + if (hasAnyOverride) { + setConfirmResetOpen(true); + return; + } + setUseDefault(true); + }; + + const toggleRow = ( + handleToggle(Boolean(checked))} + trailing={ + + } + /> + ); + + if (isViewLoading || isConfigLoading) { + return ( +
+ + +
+ ); + } + + if (configError) { + return ( + void refetchConfig()} + /> + ); + } + + return ( +
+ {toggleRow} + + {useDefault ? ( + + ) : ( + <> +
+
+ ( + + )} + /> + + form.setValue('providerModel', '')} + /> +
+ +
+ +
+
+ +
+
+
+ ( + }, + { id: 'LIGHT', label: 'Light', icon: }, + { id: 'SYSTEM', label: 'System', icon: }, + ]} + /> + )} + /> +
+ +
+

Accent Color

+ } + /> +
+
+ + +
+ + + + + + )} + + { + try { + if (orgView) await resetView(); + if (hasAiOverride) await resetAiConfig(); + setUseDefault(true); + setConfirmResetOpen(false); + toast({ + title: 'Saved', + description: 'Customer now uses the default AI-Assistant configuration', + variant: 'success', + }); + } catch (err) { + toast({ + title: 'Save failed', + description: err instanceof Error ? err.message : 'Failed to remove the custom configuration', + variant: 'destructive', + }); + } + }} + /> +
+ ); + }, +); diff --git a/src/app/(app)/customers/components/ai-assistant-appearance/use-customer-appearance-form.ts b/src/app/(app)/customers/components/customer-ai-configuration/use-customer-ai-configuration-form.ts similarity index 55% rename from src/app/(app)/customers/components/ai-assistant-appearance/use-customer-appearance-form.ts rename to src/app/(app)/customers/components/customer-ai-configuration/use-customer-ai-configuration-form.ts index e35c6ba..796a439 100644 --- a/src/app/(app)/customers/components/ai-assistant-appearance/use-customer-appearance-form.ts +++ b/src/app/(app)/customers/components/customer-ai-configuration/use-customer-ai-configuration-form.ts @@ -4,32 +4,60 @@ import { useToast } from '@flamingo-stack/openframe-frontend-core/hooks'; import { zodResolver } from '@hookform/resolvers/zod'; import { useCallback, useEffect, useRef, useState } from 'react'; import { useForm } from 'react-hook-form'; +import type { OrganizationClientAiConfig } from '@/app/(app)/settings/ai-settings/hooks/use-organization-ai-config'; import type { ClientView } from '@/app/(app)/settings/ai-settings/types/ai-settings'; +import { + type CustomerAiAssistantFormValues, + customerAiAssistantSchema, +} from '@/app/(app)/settings/ai-settings/types/customer-ai-assistant.types'; import { getFullImageUrl } from '@/lib/image-url'; import { deleteWithAuth, uploadWithAuth } from '@/lib/upload-with-auth'; -import { - type CustomerAppearanceFormValues, - customerAppearanceSchema, - getCustomerAppearanceDefaults, -} from './customer-appearance.types'; -interface UseCustomerAppearanceFormOptions { +interface UseCustomerAiConfigurationFormOptions { + /** Effective appearance (org override, or tenant default while inheriting). */ view: ClientView; + /** Effective per-org AI config (tenant values while inheriting). */ + config: OrganizationClientAiConfig | null; +} + +/** Form defaults from the effective appearance + AI config. */ +function getCustomerAiConfigurationDefaults( + view: ClientView, + config: OrganizationClientAiConfig | null, +): CustomerAiAssistantFormValues { + return { + assistantName: view.assistantName, + applicationTheme: view.applicationTheme, + accentColor: view.accentColor, + llmProvider: config?.llmProvider ?? 'ANTHROPIC', + providerModel: config?.providerModel ?? '', + answerStyle: config?.answerStyle ?? 'STANDARD', + customPrompt: config?.customPrompt ?? '', + // The org config has no explicit flag — a null action list means "no + // customs" (hub defaults apply). Mirrors getAiLogicDefaults: rows stay + // empty while defaults are active; the editor seeds them on uncheck. + quickActionsIsDefault: !config?.quickActions, + quickActions: (config?.quickActions ?? []).map(q => ({ id: q.id, name: q.name, instructions: q.instructions })), + }; } -export function useCustomerAppearanceForm({ view }: UseCustomerAppearanceFormOptions) { +/** + * Form state for the per-customer AI configuration block. Reuses the global + * CLIENT screen's schema (appearance + AI logic + quick actions); the avatar + * is deferred to `commitAvatar` so it targets the customer's own ClientView + * id, not the tenant default `view` borrowed while editing. + */ +export function useCustomerAiConfigurationForm({ view, config }: UseCustomerAiConfigurationFormOptions) { const { toast } = useToast(); - const form = useForm({ - resolver: zodResolver(customerAppearanceSchema), - defaultValues: getCustomerAppearanceDefaults(view), + const form = useForm({ + resolver: zodResolver(customerAiAssistantSchema), + defaultValues: getCustomerAiConfigurationDefaults(view, config), }); const [avatarUrl, setAvatarUrl] = useState( getFullImageUrl(view.assistantAvatar?.imageUrl, view.assistantAvatar?.hash), ); - // Avatar upload/removal is deferred to commit() so it targets the customer's - // own ClientView id, not the tenant default `view` borrowed while editing. const pendingFileRef = useRef(null); const pendingRemovalRef = useRef(false); const previewUrlRef = useRef(null); @@ -41,8 +69,8 @@ export function useCustomerAppearanceForm({ view }: UseCustomerAppearanceFormOpt } }, []); - // Re-sync form + avatar when the underlying record changes. - const syncKey = `${view.id}:${view.updatedAt ?? ''}`; + // Re-sync form + avatar when either underlying record changes. + const syncKey = `${view.id}:${view.updatedAt ?? ''}:${config?.inheritDefault ?? ''}:${config?.updatedAt ?? ''}`; const lastSyncRef = useRef(null); useEffect(() => { if (lastSyncRef.current === syncKey) return; @@ -50,9 +78,9 @@ export function useCustomerAppearanceForm({ view }: UseCustomerAppearanceFormOpt clearPreview(); pendingFileRef.current = null; pendingRemovalRef.current = false; - form.reset(getCustomerAppearanceDefaults(view)); + form.reset(getCustomerAiConfigurationDefaults(view, config)); setAvatarUrl(getFullImageUrl(view.assistantAvatar?.imageUrl, view.assistantAvatar?.hash)); - }, [syncKey, view, form, clearPreview]); + }, [syncKey, view, config, form, clearPreview]); useEffect(() => () => clearPreview(), [clearPreview]); diff --git a/src/app/(app)/customers/components/customer-details-view.tsx b/src/app/(app)/customers/components/customer-details-view.tsx index c54cd23..6fe5eb3 100644 --- a/src/app/(app)/customers/components/customer-details-view.tsx +++ b/src/app/(app)/customers/components/customer-details-view.tsx @@ -20,10 +20,11 @@ import { useQueryClient } from '@tanstack/react-query'; import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { useCallback, useMemo, useState } from 'react'; import { useClientView } from '@/app/(app)/settings/ai-settings/hooks/use-client-view'; +import { useOrganizationClientAiConfig } from '@/app/(app)/settings/ai-settings/hooks/use-organization-ai-config'; import { useSafeBack } from '@/app/hooks/use-safe-back'; import { featureFlags } from '@/lib/feature-flags'; import { getFullImageUrl } from '@/lib/image-url'; -import { routes } from '@/lib/routes'; +import { type CustomerEditTab, routes } from '@/lib/routes'; import { runtimeEnv } from '@/lib/runtime-config'; import { CONTEXT_ENTITY_KIND } from '../../mingo/context/context-types'; import { useTrackOpenView } from '../../mingo/context/use-track-open-view'; @@ -39,6 +40,13 @@ interface CustomerDetailsViewProps { id: string; } +// Details-page tabs with a counterpart on the edit page ("Edit Customer" +// deep-links into it); unmapped tabs land on the edit page's Details tab. +const DETAIL_TO_EDIT_TAB: Partial> = { + 'custom-ai-assistant': 'ai-configuration', + 'customer-ai-guardrails': 'guardrails', +}; + export function CustomerDetailsView({ id }: CustomerDetailsViewProps) { const router = useRouter(); const pathname = usePathname(); @@ -67,11 +75,16 @@ export function CustomerDetailsView({ id }: CustomerDetailsViewProps) { // customer has a custom appearance override (an org-scoped ClientView exists). const isCustomerAiEnabled = featureFlags.customerAiAssistantSettings.enabled(); const isSaasTenant = runtimeEnv.appMode() === 'saas-tenant'; - const { view: clientView } = useClientView(id, { enabled: isCustomerAiEnabled && isSaasTenant && !!id }); - const showCustomAiAssistant = isCustomerAiEnabled && isSaasTenant && !!clientView; - // Guardrails are tenant-wide defaults shown read-only per customer; no - // per-customer override exists backend-side yet, so no data precondition. - const showGuardrails = isCustomerAiEnabled && isSaasTenant; + // The Customer AI Configuration tab appears when the customer overrides any + // part of the AI setup: appearance (org ClientView) or AI logic (org config). + const aiTabQueriesEnabled = isCustomerAiEnabled && isSaasTenant && !!id; + const { view: clientView } = useClientView(id, { enabled: aiTabQueriesEnabled }); + const { config: orgAiConfig } = useOrganizationClientAiConfig(id, { enabled: aiTabQueriesEnabled }); + const showCustomAiAssistant = + isCustomerAiEnabled && isSaasTenant && (!!clientView || (!!orgAiConfig && !orgAiConfig.inheritDefault)); + // Effective per-org guardrails via /chat/graphql (saas-ai-agent), so + // saas-tenant only; own release flag, independent of the appearance feature. + const showGuardrails = featureFlags.customerGuardrails.enabled() && isSaasTenant; const tabs = useMemo( () => getCustomerTabs({ showCustomAiAssistant, showGuardrails }), [showCustomAiAssistant, showGuardrails], @@ -168,7 +181,9 @@ export function CustomerDetailsView({ id }: CustomerDetailsViewProps) { loading: isChecking, }; - const editHref = routes.customers.edit(id); + // Land on the edit tab matching the currently open details tab; tabs + // without an edit counterpart fall back to the edit page's default. + const editHref = routes.customers.edit(id, { tab: DETAIL_TO_EDIT_TAB[activeTab] }); const editAction: PageActionButton = { label: 'Edit Customer', variant: 'outline', @@ -177,7 +192,7 @@ export function CustomerDetailsView({ id }: CustomerDetailsViewProps) { }; return [archiveAction, editAction]; - }, [organization, isArchived, isChecking, handleArchiveClick, id]); + }, [organization, isArchived, isChecking, handleArchiveClick, id, activeTab]); if (isLoading) { return ; diff --git a/src/app/(app)/customers/components/customer-guardrails-settings.tsx b/src/app/(app)/customers/components/customer-guardrails-settings.tsx new file mode 100644 index 0000000..8a99684 --- /dev/null +++ b/src/app/(app)/customers/components/customer-guardrails-settings.tsx @@ -0,0 +1,372 @@ +'use client'; + +import type { ApprovalLevel } from '@flamingo-stack/openframe-frontend-core'; +import { Button, CheckboxBlock, LoadError, NoData, Skeleton } from '@flamingo-stack/openframe-frontend-core'; +import { PenEditIcon, ShieldCheckIcon } from '@flamingo-stack/openframe-frontend-core/components/icons-v2'; +import { useToast } from '@flamingo-stack/openframe-frontend-core/hooks'; +import { useRouter } from 'next/navigation'; +import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; +import { buildPolicyGroups } from '@/app/(app)/settings/ai-settings/components/guardrails/build-policy-groups'; +import { + CUSTOM_POLICY_DESCRIPTION, + CUSTOM_POLICY_TYPE, +} from '@/app/(app)/settings/ai-settings/components/guardrails/guardrails.types'; +import { GuardrailsPolicyGroups } from '@/app/(app)/settings/ai-settings/components/guardrails/guardrails-policy-groups'; +import { GuardrailsPresetCard } from '@/app/(app)/settings/ai-settings/components/guardrails/guardrails-preset-card'; +import { + type GuardrailsTemplateOption, + GuardrailsTemplatePicker, +} from '@/app/(app)/settings/ai-settings/components/guardrails/guardrails-template-picker'; +import { + useGuardrailsTemplate, + useGuardrailsTemplates, +} from '@/app/(app)/settings/ai-settings/components/guardrails/use-guardrails-policies'; +import { + useOrganizationGuardrails, + useResetOrganizationGuardrails, + useUpdateOrganizationGuardrails, +} from '@/app/(app)/settings/ai-settings/components/guardrails/use-organization-guardrails'; +import { ConfirmDialog } from '@/app/components/shared/confirm-dialog'; +import { routes } from '@/lib/routes'; + +/** Imperative API the parent ("Save Customer") drives to persist this block. */ +export interface CustomerGuardrailsHandle { + /** Persists the per-org guardrails selection. No-op when inheriting or unchanged. */ + commit: () => Promise; +} + +interface CustomerGuardrailsSettingsProps { + organizationId: string; +} + +/** Radio value for the synthetic "Custom" option (the org has no server-side custom template). */ +const ORG_CUSTOM_OPTION_ID = 'ORG_CUSTOM'; + +/** + * What the user picked this session. `custom.edits` are approval-level + * overrides diffed against the base TEMPLATE rules — exactly what + * `updateOrganizationGuardrails` persists. + */ +type OrgGuardrailsSelection = + | { kind: 'template'; templateId: string } + | { kind: 'custom'; baseTemplateId: string; edits: Map }; + +/** + * "Customer AI Guardrails" tab on the customer edit page. Mirrors the + * AI-Assistant appearance block: no own Save button — the page's + * "Save Customer" persists via the `commit()` ref handle. "Use the default + * guardrails settings" ON means the org inherits the tenant defaults (an + * existing org policy is reset immediately once the user confirms); OFF edits + * the org's own policy: a stock preset as-is, or a custom set of per-operation + * approval overrides on top of one. + */ +export const CustomerGuardrailsSettings = forwardRef( + function CustomerGuardrailsSettings({ organizationId }, ref) { + const router = useRouter(); + const { toast } = useToast(); + + const { guardrails, isLoading: isGuardrailsLoading, error, refetch } = useOrganizationGuardrails(organizationId); + const { + templates, + activeTemplateId, + isLoading: isTemplatesLoading, + refetch: refetchTemplates, + } = useGuardrailsTemplates(); + const { update } = useUpdateOrganizationGuardrails(organizationId); + const { reset, isPending: isResetting } = useResetOrganizationGuardrails(organizationId); + + const [useDefault, setUseDefault] = useState(true); + const [selection, setSelection] = useState(null); + const [confirmResetOpen, setConfirmResetOpen] = useState(false); + + // Org guardrails are always materialized from a stock TEMPLATE — the + // tenant's own custom policy is not a valid base. + const stockTemplates = useMemo(() => templates.filter(t => t.type !== CUSTOM_POLICY_TYPE), [templates]); + + // Seed toggle + selection once both queries have loaded: an org with its + // own policy starts on it (preset or custom), otherwise on the tenant's + // active preset so unchecking the toggle has a sensible starting point. + const seededRef = useRef(false); + useEffect(() => { + if (seededRef.current || isGuardrailsLoading || isTemplatesLoading || !guardrails || !stockTemplates.length) { + return; + } + seededRef.current = true; + + setUseDefault(guardrails.inheritDefault); + const fallbackId = (stockTemplates.find(t => t.id === activeTemplateId) ?? stockTemplates[0]).id; + if (guardrails.inheritDefault) { + setSelection({ kind: 'template', templateId: fallbackId }); + } else { + const baseTemplateId = guardrails.sourceTemplate ?? fallbackId; + setSelection( + guardrails.overrides.length > 0 + ? { + kind: 'custom', + baseTemplateId, + edits: new Map(guardrails.overrides.map(o => [o.naturalKey, o.approvalLevel])), + } + : { kind: 'template', templateId: baseTemplateId }, + ); + } + }, [isGuardrailsLoading, isTemplatesLoading, guardrails, stockTemplates, activeTemplateId]); + + // Rules preview/editing always renders from the base TEMPLATE's rules + // (react-query caches make preset switching instant). + const displayTemplateId = selection + ? selection.kind === 'template' + ? selection.templateId + : selection.baseTemplateId + : null; + const { template: displayTemplate, isLoading: isDetailLoading } = useGuardrailsTemplate( + useDefault ? null : displayTemplateId, + ); + + const baseLevels = useMemo(() => { + const levels = new Map(); + for (const rule of displayTemplate?.rules ?? []) { + levels.set(rule.naturalKey, rule.approvalLevel); + } + return levels; + }, [displayTemplate]); + + const editorGroups = useMemo(() => { + const rules = displayTemplate?.rules ?? []; + const edits = selection?.kind === 'custom' ? selection.edits : null; + const effectiveRules = edits?.size + ? rules.map(rule => { + const level = edits.get(rule.naturalKey); + return level ? { ...rule, approvalLevel: level } : rule; + }) + : rules; + return buildPolicyGroups(effectiveRules); + }, [displayTemplate, selection]); + + // Inherited view (toggle ON): the org query already returns the effective + // tenant rules while inheriting. + const inheritedGroups = useMemo(() => buildPolicyGroups(guardrails?.rules ?? []), [guardrails]); + + const templateOptions = useMemo( + () => [ + ...stockTemplates.map(t => ({ + id: t.id, + label: t.displayName, + description: t.description, + isCustom: false, + })), + { id: ORG_CUSTOM_OPTION_ID, label: 'Custom', description: CUSTOM_POLICY_DESCRIPTION, isCustom: true }, + ], + [stockTemplates], + ); + + const selectOption = useCallback((templateId: string) => { + setSelection(prev => { + if (templateId !== ORG_CUSTOM_OPTION_ID) return { kind: 'template', templateId }; + if (!prev || prev.kind === 'custom') return prev; + return { kind: 'custom', baseTemplateId: prev.templateId, edits: new Map() }; + }); + }, []); + + const createCustomPolicyFrom = useCallback((baseTemplateId: string) => { + setSelection({ kind: 'custom', baseTemplateId, edits: new Map() }); + }, []); + + const withEdit = useCallback( + (edits: Map, naturalKey: string, level: ApprovalLevel) => { + if (baseLevels.get(naturalKey) === level) edits.delete(naturalKey); + else edits.set(naturalKey, level); + }, + [baseLevels], + ); + + const setPolicyPermission = useCallback( + (_categoryId: string, policyId: string, level: ApprovalLevel) => { + setSelection(prev => { + if (prev?.kind !== 'custom') return prev; + const edits = new Map(prev.edits); + withEdit(edits, policyId, level); + return { ...prev, edits }; + }); + }, + [withEdit], + ); + + const allCategories = useMemo(() => Array.from(editorGroups.values()).flat(), [editorGroups]); + + const applyCategoryPermission = useCallback( + (categoryId: string, level: ApprovalLevel) => { + const category = allCategories.find(c => c.id === categoryId); + if (!category) return; + setSelection(prev => { + if (prev?.kind !== 'custom') return prev; + const edits = new Map(prev.edits); + for (const policy of category.policies) { + withEdit(edits, policy.naturalKey, level); + } + return { ...prev, edits }; + }); + }, + [allCategories, withEdit], + ); + + const isDirty = useMemo(() => { + if (useDefault || !guardrails || !selection) return false; + if (guardrails.inheritDefault) return true; // creating the org's own policy + if (selection.kind === 'template') { + return selection.templateId !== guardrails.sourceTemplate || guardrails.overrides.length > 0; + } + if (selection.baseTemplateId !== guardrails.sourceTemplate) return true; + if (selection.edits.size !== guardrails.overrides.length) return true; + return guardrails.overrides.some(o => selection.edits.get(o.naturalKey) !== o.approvalLevel); + }, [useDefault, guardrails, selection]); + + useImperativeHandle( + ref, + () => ({ + commit: async () => { + // Inheriting: an existing org policy was already reset when the user + // confirmed the toggle, so there is nothing to persist here. + if (useDefault || !selection || !isDirty) return; + await update( + selection.kind === 'template' + ? { templateId: selection.templateId, overrides: [] } + : { + templateId: selection.baseTemplateId, + overrides: Array.from(selection.edits, ([naturalKey, approvalLevel]) => ({ + naturalKey, + approvalLevel, + })), + }, + ); + }, + }), + [useDefault, selection, isDirty, update], + ); + + const handleToggle = (checked: boolean) => { + if (!checked) { + setUseDefault(false); + return; + } + // Switching back to defaults: confirm first; the org policy is reset on confirm. + if (guardrails && !guardrails.inheritDefault) { + setConfirmResetOpen(true); + return; + } + setUseDefault(true); + }; + + if (isGuardrailsLoading || isTemplatesLoading) { + return ( +
+ + +
+ ); + } + + if (error || !guardrails) { + return ( + { + void refetch(); + void refetchTemplates(); + }} + /> + ); + } + + const activePresetLabel = templates.find(t => t.id === activeTemplateId)?.displayName || 'None'; + const selectedRadioValue = selection?.kind === 'custom' ? ORG_CUSTOM_OPTION_ID : (selection?.templateId ?? ''); + + return ( +
+ handleToggle(Boolean(checked))} + trailing={ + + } + /> + + {useDefault ? ( + <> + + {inheritedGroups.size > 0 && } + + ) : ( + <> + + + {isDetailLoading ? ( + + ) : editorGroups.size === 0 ? ( + } + title="This policy template has no rules" + className="py-[var(--spacing-system-xxl)]" + /> + ) : ( + + )} + + )} + + { + try { + await reset(); + setUseDefault(true); + setConfirmResetOpen(false); + toast({ + title: 'Saved', + description: 'Customer now follows the default guardrails', + variant: 'success', + }); + } catch (err) { + toast({ + title: 'Save failed', + description: err instanceof Error ? err.message : 'Failed to remove the custom guardrails', + variant: 'destructive', + }); + } + }} + /> +
+ ); + }, +); diff --git a/src/app/(app)/customers/components/details-tabs/customer-custom-ai-assistant-tab.tsx b/src/app/(app)/customers/components/details-tabs/customer-custom-ai-assistant-tab.tsx index 3c75b47..5385492 100644 --- a/src/app/(app)/customers/components/details-tabs/customer-custom-ai-assistant-tab.tsx +++ b/src/app/(app)/customers/components/details-tabs/customer-custom-ai-assistant-tab.tsx @@ -1,29 +1,40 @@ 'use client'; -import { EntityImage, Skeleton } from '@flamingo-stack/openframe-frontend-core/components/ui'; -import { cn } from '@flamingo-stack/openframe-frontend-core/utils'; -import { AiSettingsPreviews } from '@/app/(app)/settings/ai-settings/components/previews/ai-settings-previews'; +import { LoadError, Skeleton } from '@flamingo-stack/openframe-frontend-core/components/ui'; +import { AiSettingsOverview } from '@/app/(app)/settings/ai-settings/components/ai-settings-overview'; import { useClientView } from '@/app/(app)/settings/ai-settings/hooks/use-client-view'; -import { APPLICATION_THEME_LABEL } from '@/app/(app)/settings/ai-settings/utils/ai-settings-display'; -import { InfoCell } from '@/app/components/shared/info-cell'; -import { getFullImageUrl } from '@/lib/image-url'; +import { useOrganizationClientAiConfig } from '@/app/(app)/settings/ai-settings/hooks/use-organization-ai-config'; +import { getProviderModelLabel, useSupportedModels } from '@/app/(app)/settings/ai-settings/hooks/use-supported-models'; +import { + type AgentAiConfig, + getDefaultAgentAiConfig, + getDefaultClientView, +} from '@/app/(app)/settings/ai-settings/types/ai-settings'; interface CustomerCustomAiAssistantTabProps { organizationId: string; } -const CELL = 'flex items-center gap-2 min-h-14 md:min-h-20 px-3 md:px-4 py-3 md:py-4'; - /** - * Read-only view of a customer's custom AI-Assistant appearance (org-scoped - * ClientView override). Shown as a tab on the customer details page only when an - * override exists; editing happens on /customers/edit. + * Read-only "Customer AI Configuration" tab on the customer details page. + * Renders the same overview structure as the global AI settings CLIENT tab + * (AiSettingsOverview: customer card + previews + quick actions), fed with the + * customer's EFFECTIVE values — the org overrides where present, the tenant + * defaults otherwise. Editing happens on /customers/edit. */ export function CustomerCustomAiAssistantTab({ organizationId }: CustomerCustomAiAssistantTabProps) { - // Shares the react-query cache with the parent's visibility check. - const { view, isLoading } = useClientView(organizationId); + // Shares the react-query caches with the parent's visibility check. + const { view: orgView, isLoading: isViewLoading } = useClientView(organizationId); + const { view: defaultView } = useClientView(null); + const { + config: orgConfig, + isLoading: isConfigLoading, + error: configError, + refetch: refetchConfig, + } = useOrganizationClientAiConfig(organizationId); + const { modelsByProvider } = useSupportedModels(); - if (isLoading) { + if (isViewLoading || isConfigLoading) { return (
@@ -32,39 +43,34 @@ export function CustomerCustomAiAssistantTab({ organizationId }: CustomerCustomA ); } - // The tab is only mounted when an override exists, but guard defensively. - if (!view) { - return null; + if (configError) { + return ( + void refetchConfig()} + /> + ); } - const avatarUrl = getFullImageUrl(view.assistantAvatar?.imageUrl, view.assistantAvatar?.hash); + const effectiveView = orgView ?? defaultView ?? getDefaultClientView(organizationId); + // AiSettingsOverview consumes the tenant-level AgentAiConfig shape; project + // the effective org values onto it (nullable fields fall back like the + // global screen's defaults). + const aiConfig: AgentAiConfig = { + ...getDefaultAgentAiConfig('CLIENT'), + llmProvider: orgConfig?.llmProvider ?? 'ANTHROPIC', + providerModel: orgConfig?.providerModel ?? '', + answerStyle: orgConfig?.answerStyle ?? null, + customPrompt: orgConfig?.customPrompt ?? null, + quickActions: orgConfig?.quickActions ?? [], + }; return ( -
-
-
- {/* EntityImage defaults to size-[52px] md:size-[60px]; override both - breakpoints so the avatar stays 40×40. */} - - -
- -
-
- -
-
- -
-
-
- - -
+ ); } diff --git a/src/app/(app)/customers/components/details-tabs/customer-guardrails-tab.tsx b/src/app/(app)/customers/components/details-tabs/customer-guardrails-tab.tsx index d80f267..c586131 100644 --- a/src/app/(app)/customers/components/details-tabs/customer-guardrails-tab.tsx +++ b/src/app/(app)/customers/components/details-tabs/customer-guardrails-tab.tsx @@ -1,45 +1,99 @@ 'use client'; -import { InfoCircleIcon, PenEditIcon } from '@flamingo-stack/openframe-frontend-core/components/icons-v2'; +import { LoadError, NoData, Skeleton } from '@flamingo-stack/openframe-frontend-core'; +import { + InfoCircleIcon, + PenEditIcon, + ShieldCheckIcon, +} from '@flamingo-stack/openframe-frontend-core/components/icons-v2'; import { Button } from '@flamingo-stack/openframe-frontend-core/components/ui'; import { useRouter } from 'next/navigation'; -import { GuardrailsPanel } from '@/app/(app)/settings/ai-settings/components/guardrails/guardrails-panel'; -import { useGuardrailsEditor } from '@/app/(app)/settings/ai-settings/components/guardrails/use-guardrails-editor'; +import { useMemo } from 'react'; +import { buildPolicyGroups } from '@/app/(app)/settings/ai-settings/components/guardrails/build-policy-groups'; +import { GuardrailsPolicyGroups } from '@/app/(app)/settings/ai-settings/components/guardrails/guardrails-policy-groups'; +import { GuardrailsPresetCard } from '@/app/(app)/settings/ai-settings/components/guardrails/guardrails-preset-card'; +import { useGuardrailsTemplates } from '@/app/(app)/settings/ai-settings/components/guardrails/use-guardrails-policies'; +import { useOrganizationGuardrails } from '@/app/(app)/settings/ai-settings/components/guardrails/use-organization-guardrails'; import { routes } from '@/lib/routes'; +interface CustomerGuardrailsTabProps { + organizationId: string; +} + /** - * Read-only "Customer AI Guardrails" tab on the customer details page. The - * backend has no per-organization policies yet, so every customer follows the - * tenant-wide defaults — this shows them (real data, tenant-scoped) with a - * pointer to AI Settings → Guardrails for editing. Once org-scoped policies - * land, the scope enters via the guardrails data hooks and this banner becomes - * conditional on an override existing. + * "Customer AI Guardrails" tab on the customer details page. Reads the + * organization's EFFECTIVE guardrails via `organizationGuardrails` — tenant + * default rules while the org inherits (`inheritDefault`), the org's own + * materialized policy otherwise. Read-only: tenant defaults are edited in + * AI Settings → Guardrails; a per-customer edit flow can build on the + * `updateOrganizationGuardrails` / `resetOrganizationGuardrails` mutations. */ -export function CustomerGuardrailsTab() { +export function CustomerGuardrailsTab({ organizationId }: CustomerGuardrailsTabProps) { const router = useRouter(); - const editor = useGuardrailsEditor({ isEditMode: false }); + const { guardrails, isLoading, error, refetch } = useOrganizationGuardrails(organizationId); + // Tenant preset list (REST): resolves display names for the preset card. + const { templates, activeTemplateId, isLoading: isTemplatesLoading } = useGuardrailsTemplates(); + + const policyGroups = useMemo(() => buildPolicyGroups(guardrails?.rules ?? []), [guardrails]); + + if (isLoading || isTemplatesLoading) { + return ( +
+ + + +
+ ); + } + + if (error || !guardrails) { + return ( + void refetch()} + /> + ); + } + + const inheritsDefault = guardrails.inheritDefault; + const sourceTemplateLabel = templates.find(t => t.id === guardrails.sourceTemplate)?.displayName; + const presetLabel = inheritsDefault + ? templates.find(t => t.id === activeTemplateId)?.displayName || 'None' + : `Custom Policy${sourceTemplateLabel ? ` (based on ${sourceTemplateLabel})` : ''}`; return (
-
-
- -
-

Using Default Settings

-

This customer follows guardrails defaults.

+ {inheritsDefault && ( +
+
+ +
+

Using Default Settings

+

This customer follows guardrails defaults.

+
+
- -
+ )} + + - + {policyGroups.size === 0 ? ( + } + title="No guardrail rules configured" + className="py-[var(--spacing-system-xxl)]" + /> + ) : ( + + )}
); } diff --git a/src/app/(app)/customers/components/details-tabs/customer-tabs.tsx b/src/app/(app)/customers/components/details-tabs/customer-tabs.tsx index 629029a..33b65a0 100644 --- a/src/app/(app)/customers/components/details-tabs/customer-tabs.tsx +++ b/src/app/(app)/customers/components/details-tabs/customer-tabs.tsx @@ -6,7 +6,7 @@ import { ClockHistoryIcon, FileContentIcon, MonitorIcon, - RewardBadgeCheckIcon, + ShieldCheckIcon, TagIcon, } from '@flamingo-stack/openframe-frontend-core/components/icons-v2'; import type { TabItem } from '@flamingo-stack/openframe-frontend-core/components/ui'; @@ -54,8 +54,8 @@ function CustomAiAssistantTab({ organization }: CustomerTabProps) { return ; } -function GuardrailsTab(_props: CustomerTabProps) { - return ; +function GuardrailsTab({ organization }: CustomerTabProps) { + return ; } export const CUSTOM_AI_ASSISTANT_TAB_ID = 'custom-ai-assistant'; @@ -71,7 +71,7 @@ const BASE_CUSTOMER_TABS: TabItem[] = [ const CUSTOM_AI_ASSISTANT_TAB: TabItem = { id: CUSTOM_AI_ASSISTANT_TAB_ID, - label: 'Custom AI Assistant', + label: 'Customer AI Configuration', icon: ChatsIcon, component: CustomAiAssistantTab, }; @@ -79,7 +79,7 @@ const CUSTOM_AI_ASSISTANT_TAB: TabItem = { const CUSTOMER_GUARDRAILS_TAB: TabItem = { id: CUSTOMER_GUARDRAILS_TAB_ID, label: 'Customer AI Guardrails', - icon: RewardBadgeCheckIcon, + icon: ShieldCheckIcon, component: GuardrailsTab, }; diff --git a/src/app/(app)/customers/components/new-customer-page.tsx b/src/app/(app)/customers/components/new-customer-page.tsx index a018cc8..a0effdc 100644 --- a/src/app/(app)/customers/components/new-customer-page.tsx +++ b/src/app/(app)/customers/components/new-customer-page.tsx @@ -1,16 +1,23 @@ 'use client'; +import { + ChatsIcon, + FileContentIcon, + ShieldCheckIcon, +} from '@flamingo-stack/openframe-frontend-core/components/icons-v2'; import { CheckboxBlock, ImageUploader, Input, PageLayout, + type TabItem, + TabNavigation, Textarea, } from '@flamingo-stack/openframe-frontend-core/components/ui'; import { useToast } from '@flamingo-stack/openframe-frontend-core/hooks'; import { useQueryClient } from '@tanstack/react-query'; -import { useRouter } from 'next/navigation'; -import React, { useEffect, useRef, useState } from 'react'; +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { safeBackOrReplace, useSafeBack } from '@/app/hooks/use-safe-back'; import { featureFlags } from '@/lib/feature-flags'; import { getFullImageUrl } from '@/lib/image-url'; @@ -22,9 +29,10 @@ import { useCreateCustomer } from '../hooks/use-create-customer'; import { customerDetailsQueryKeys, useCustomerDetails } from '../hooks/use-customer-details'; import { useUpdateCustomer } from '../hooks/use-update-customer'; import { - CustomerAiAssistantAppearance, - type CustomerAppearanceHandle, -} from './ai-assistant-appearance/customer-ai-assistant-appearance'; + CustomerAiConfiguration, + type CustomerAiConfigurationHandle, +} from './customer-ai-configuration/customer-ai-configuration'; +import { type CustomerGuardrailsHandle, CustomerGuardrailsSettings } from './customer-guardrails-settings'; interface NewCustomerPageProps { organizationId: string | null; @@ -88,6 +96,8 @@ const contactToDto = (c: { name: string; title: string; phone: string; email: st export function NewCustomerPage({ organizationId }: NewCustomerPageProps) { const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); const { toast } = useToast(); const queryClient = useQueryClient(); @@ -106,13 +116,46 @@ export function NewCustomerPage({ organizationId }: NewCustomerPageProps) { const [pendingFile, setPendingFile] = useState(null); const [pendingPreviewUrl, setPendingPreviewUrl] = useState(undefined); const previewUrlRef = useRef(undefined); - // Lets "Save Customer" also persist the AI-Assistant appearance block. - const appearanceRef = useRef(null); + // Let "Save Customer" also persist the AI configuration / guardrails blocks. + const aiConfigurationRef = useRef(null); + const guardrailsRef = useRef(null); const isSaasTenant = runtimeEnv.appMode() === 'saas-tenant'; const showImageUploader = isSaasTenant; const displayedImage = pendingPreviewUrl || getFullImageUrl(form.imageUrl, form.imageHash); + // Per-customer AI blocks: SaaS-only (they rely on the openframe-saas-ai-agent + // service, absent in self-hosted) and edit-mode only (they need an org id to + // scope the override). Each is gated behind its own release flag. When any is + // visible, the page renders as tabs (Details / AI Configuration / Guardrails). + const showAppearance = !!organizationId && isSaasTenant && featureFlags.customerAiAssistantSettings.enabled(); + const showGuardrails = !!organizationId && isSaasTenant && featureFlags.customerGuardrails.enabled(); + const showTabs = showAppearance || showGuardrails; + + const editTabs = useMemo( + () => [ + { id: 'details', label: 'Details', icon: FileContentIcon }, + ...(showAppearance ? [{ id: 'ai-configuration', label: 'Customer AI Configuration', icon: ChatsIcon }] : []), + ...(showGuardrails ? [{ id: 'guardrails', label: 'Customer AI Guardrails', icon: ShieldCheckIcon }] : []), + ], + [showAppearance, showGuardrails], + ); + + // Tab rides the URL (controlled mode, mirroring customer-details-view) so + // "Edit Customer" from a details-page tab lands on the matching edit tab and + // a refresh keeps the current one. Unknown or flag-hidden tab ids fall back + // to Details. Panels stay mounted across switches, so form state survives. + const requestedTab = searchParams?.get('tab') ?? 'details'; + const activeTab = editTabs.some(tab => tab.id === requestedTab) ? requestedTab : 'details'; + const handleTabChange = useCallback( + (tabId: string) => { + const params = new URLSearchParams(searchParams?.toString() ?? ''); + params.set('tab', tabId); + router.replace(`${pathname}?${params.toString()}`, { scroll: false }); + }, + [router, pathname, searchParams], + ); + const set = (partial: Partial) => setForm(prev => ({ ...prev, ...partial })); // Revoke blob URLs on unmount @@ -247,11 +290,11 @@ export function NewCustomerPage({ organizationId }: NewCustomerPageProps) { try { setIsSubmitting(true); - // Validate the AI-Assistant appearance fields before writing anything. - if (appearanceRef.current && !(await appearanceRef.current.validate())) { + // Validate the AI configuration fields before writing anything. + if (aiConfigurationRef.current && !(await aiConfigurationRef.current.validate())) { toast({ - title: 'Check AI-Assistant appearance', - description: 'Fix the highlighted appearance fields before saving', + title: 'Check AI configuration', + description: 'Fix the highlighted AI configuration fields before saving', variant: 'destructive', }); setIsSubmitting(false); @@ -297,16 +340,30 @@ export function NewCustomerPage({ organizationId }: NewCustomerPageProps) { } } - // Persist the AI-Assistant appearance override/reset (edit mode only). - // The customer is already saved at this point, so an appearance failure is + // Persist the AI configuration overrides/reset (edit mode only). The + // customer is already saved at this point, so a configuration failure is // a non-fatal warning — it must not surface as a full "Save failed". - if (organizationId && appearanceRef.current) { + if (organizationId && aiConfigurationRef.current) { + try { + await aiConfigurationRef.current.commit(); + } catch (e) { + toast({ + title: 'Customer saved, AI configuration not updated', + description: e instanceof Error ? e.message : 'Failed to save the customer AI configuration', + variant: 'warning', + }); + } + } + + // Persist the per-customer guardrails selection (edit mode only). Same + // non-fatal semantics as the appearance block: the customer is saved. + if (organizationId && guardrailsRef.current) { try { - await appearanceRef.current.commit(); + await guardrailsRef.current.commit(); } catch (e) { toast({ - title: 'Customer saved, appearance not updated', - description: e instanceof Error ? e.message : 'Failed to save AI-Assistant appearance', + title: 'Customer saved, guardrails not updated', + description: e instanceof Error ? e.message : 'Failed to save customer guardrails', variant: 'warning', }); } @@ -333,6 +390,84 @@ export function NewCustomerPage({ organizationId }: NewCustomerPageProps) { const saveDisabled = !form.name.trim() || isSubmitting; + const detailsForm = ( +
+ {/* Row 1: name + website (left) | image (right on lg, below on md/sm) */} +
+
+
+ set({ name: e.target.value })} + /> +
+
+ set({ website: e.target.value })} + /> +
+
+ + {showImageUploader && ( +
+ +
+ )} +
+ + {/* Notes */} +