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..7a028cd 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.446", "@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.446", + "resolved": "https://registry.npmjs.org/@flamingo-stack/openframe-frontend-core/-/openframe-frontend-core-0.0.446.tgz", + "integrity": "sha512-WN81ghVxAywiF4dglDfHpOSqJ0fv0thkEVlDVKz86i9MN0iVCFv18otQen9PWwkfn2Xrm99xuKGWhwC7/ex3Gw==", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", diff --git a/package.json b/package.json index 223f538..3bdbc9b 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.446", "@hookform/resolvers": "^5.2.2", "@monaco-editor/react": "^4.7.0", "@tanstack/react-query": "^5.90.16", 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)/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..ceebca7 --- /dev/null +++ b/src/app/(app)/customers/components/customer-ai-configuration/customer-ai-configuration.tsx @@ -0,0 +1,380 @@ +'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, + useResetOrganizationClientAiQuickActions, + 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 { resetQuickActions } = useResetOrganizationClientAiQuickActions(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, + () => ({ + // Until the seed effect has reflected server state into the toggle + // (org queries loading or errored), this block must not validate or + // write — an unseeded `useDefault: true` would silently reset the + // customer's existing overrides on an unrelated "Save Customer". + validate: () => (!seededRef.current || useDefault ? Promise.resolve(true) : form.trigger()), + commit: async () => { + if (!seededRef.current) return; + 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, + }); + + await updateAiConfig(toAgentAiConfigInput(values)); + // The update omits `quickActions` while defaults are on, and the + // backend ignores `quickActionsIsDefault` for orgs — re-checking + // "use defaults" over an existing custom list needs the dedicated + // reset mutation or it would be a silent no-op. + if (values.quickActionsIsDefault && hasAiOverride && orgConfig?.quickActions?.length) { + await resetQuickActions(); + } + + // Avatar last: its failure must not drop the config writes above. + 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 where both are current. + await queryClient.invalidateQueries({ queryKey: clientViewQueryKeys.detail(organizationId) }); + }, + }), + [ + useDefault, + organizationId, + orgView, + orgConfig, + hasAiOverride, + form, + updateView, + updateAiConfig, + resetView, + resetAiConfig, + resetQuickActions, + 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 52% 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..d4e5e37 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,61 @@ 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 { ClientView } from '@/app/(app)/settings/ai-settings/types/ai-settings'; +import type { OrganizationClientAiConfig } from '@/app/(app)/settings/ai-settings/hooks/use-organization-ai-config'; +import { type ClientView, getDefaultAgentAiConfig } 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 { + const baseConfig = getDefaultAgentAiConfig('CLIENT'); + return { + assistantName: view.assistantName, + applicationTheme: view.applicationTheme, + accentColor: view.accentColor, + llmProvider: config?.llmProvider ?? baseConfig.llmProvider, + providerModel: config?.providerModel ?? baseConfig.providerModel, + answerStyle: config?.answerStyle ?? baseConfig.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 +70,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 +79,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 f9dddae..f2f0f13 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 CustomerDetailTab, 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'; @@ -32,13 +33,25 @@ import { customerDetailsQueryKeys, useCustomerDetails } from '../hooks/use-custo import { customersQueryKeys } from '../hooks/use-customers'; import { ArchiveCustomerModal } from './archive-customer-modal'; import { CustomerDetailsSkeleton } from './customer-details-skeleton'; -import { getCustomerTabComponent, getCustomerTabs } from './details-tabs/customer-tabs'; +import { + CUSTOM_AI_ASSISTANT_TAB_ID, + CUSTOMER_GUARDRAILS_TAB_ID, + getCustomerTabComponent, + getCustomerTabs, +} from './details-tabs/customer-tabs'; import { RestoreCustomerModal } from './restore-customer-modal'; 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_TAB_ID]: 'ai-configuration', + [CUSTOMER_GUARDRAILS_TAB_ID]: 'guardrails', +}; + export function CustomerDetailsView({ id }: CustomerDetailsViewProps) { const router = useRouter(); const pathname = usePathname(); @@ -67,10 +80,21 @@ 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; - const tabs = useMemo(() => getCustomerTabs(showCustomAiAssistant), [showCustomAiAssistant]); - const activeTab = tabs.some(tab => tab.id === requestedTab) ? requestedTab : 'devices'; + // 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], + ); + const activeTab = (tabs.some(tab => tab.id === requestedTab) ? requestedTab : 'devices') as CustomerDetailTab; // Register this organization as the Mingo "open view". useTrackOpenView( @@ -162,7 +186,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', @@ -171,7 +197,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..ca63c8c --- /dev/null +++ b/src/app/(app)/customers/components/customer-guardrails-settings.tsx @@ -0,0 +1,355 @@ +'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 { + applyEditsToRules, + buildBaseLevels, + withCategoryEdits, + withPolicyEdit, +} from '@/app/(app)/settings/ai-settings/components/guardrails/rule-edits'; +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, + error: templatesError, + 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(() => buildBaseLevels(displayTemplate?.rules ?? []), [displayTemplate]); + + const editorGroups = useMemo(() => { + const edits = selection?.kind === 'custom' ? selection.edits : null; + return buildPolicyGroups(applyEditsToRules(displayTemplate?.rules ?? [], edits)); + }, [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 setPolicyPermission = useCallback( + (_categoryId: string, policyId: string, level: ApprovalLevel) => { + setSelection(prev => { + if (prev?.kind !== 'custom') return prev; + return { ...prev, edits: withPolicyEdit(prev.edits, baseLevels, policyId, level) }; + }); + }, + [baseLevels], + ); + + 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; + return { ...prev, edits: withCategoryEdits(prev.edits, baseLevels, category, level) }; + }); + }, + [allCategories, baseLevels], + ); + + 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 ( +
+ + +
+ ); + } + + // The templates list drives seeding, the picker, and preset labels — a + // failure there would otherwise misrender the org as "using defaults" + // with a dead picker, so it is as fatal as the org query failing. + if (error || templatesError || !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 new file mode 100644 index 0000000..0a7066a --- /dev/null +++ b/src/app/(app)/customers/components/details-tabs/customer-guardrails-tab.tsx @@ -0,0 +1,103 @@ +'use client'; + +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 { 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; +} + +/** + * "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({ organizationId }: CustomerGuardrailsTabProps) { + const router = useRouter(); + 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; + // An org policy without overrides IS the source preset (materialized as-is + // by the edit page's preset radio) — only overridden rules make it custom. + const presetLabel = inheritsDefault + ? templates.find(t => t.id === activeTemplateId)?.displayName || 'None' + : guardrails.overrides.length === 0 && sourceTemplateLabel + ? sourceTemplateLabel + : `Custom Policy${sourceTemplateLabel ? ` (based on ${sourceTemplateLabel})` : ''}`; + + return ( +
+ {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 aec838b..43cd612 100644 --- a/src/app/(app)/customers/components/details-tabs/customer-tabs.tsx +++ b/src/app/(app)/customers/components/details-tabs/customer-tabs.tsx @@ -6,14 +6,17 @@ import { ClockHistoryIcon, FileContentIcon, MonitorIcon, + ShieldCheckIcon, TagIcon, } from '@flamingo-stack/openframe-frontend-core/components/icons-v2'; import type { TabItem } from '@flamingo-stack/openframe-frontend-core/components/ui'; import type { ComponentType } from 'react'; +import type { CustomerDetailTab } from '@/lib/routes'; 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 +55,14 @@ function CustomAiAssistantTab({ organization }: CustomerTabProps) { return ; } -export const CUSTOM_AI_ASSISTANT_TAB_ID = 'custom-ai-assistant'; +function GuardrailsTab({ organization }: CustomerTabProps) { + return ; +} + +// `satisfies` links these ids to the route registry's tab union — renaming a +// tab in TAB_IDS.customerDetails without updating them fails tsc. +export const CUSTOM_AI_ASSISTANT_TAB_ID = 'custom-ai-assistant' satisfies CustomerDetailTab; +export const CUSTOMER_GUARDRAILS_TAB_ID = 'customer-ai-guardrails' satisfies CustomerDetailTab; const BASE_CUSTOMER_TABS: TabItem[] = [ { id: 'devices', label: 'Devices', icon: MonitorIcon, component: DevicesTab }, @@ -64,20 +74,34 @@ 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, }; +const CUSTOMER_GUARDRAILS_TAB: TabItem = { + id: CUSTOMER_GUARDRAILS_TAB_ID, + label: 'Customer AI Guardrails', + icon: ShieldCheckIcon, + 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)/customers/components/new-customer-page.tsx b/src/app/(app)/customers/components/new-customer-page.tsx index a018cc8..d44d223 100644 --- a/src/app/(app)/customers/components/new-customer-page.tsx +++ b/src/app/(app)/customers/components/new-customer-page.tsx @@ -1,20 +1,27 @@ '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'; -import { routes } from '@/lib/routes'; +import { routes, TAB_IDS } from '@/lib/routes'; import { runtimeEnv } from '@/lib/runtime-config'; import { deleteWithAuth, uploadWithAuth } from '@/lib/upload-with-auth'; import { dashboardQueryKeys } from '../../dashboard/utils/query-keys'; @@ -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; @@ -86,8 +94,12 @@ const contactToDto = (c: { name: string; title: string; phone: string; email: st email: c.email, }); +const [DETAILS_TAB, AI_CONFIGURATION_TAB, GUARDRAILS_TAB] = TAB_IDS.customerEdit; + export function NewCustomerPage({ organizationId }: NewCustomerPageProps) { const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); const { toast } = useToast(); const queryClient = useQueryClient(); @@ -106,13 +118,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_TAB, label: 'Details', icon: FileContentIcon }, + ...(showAppearance ? [{ id: AI_CONFIGURATION_TAB, label: 'Customer AI Configuration', icon: ChatsIcon }] : []), + ...(showGuardrails ? [{ id: GUARDRAILS_TAB, 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_TAB; + const activeTab = editTabs.some(tab => tab.id === requestedTab) ? requestedTab : DETAILS_TAB; + 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 +292,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 +342,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 +392,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 */} +