@carlonicora/nextjs-jsonapi 3.0.1 → 3.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/dist/AbstractApiData-DtitRWPz.d.ts +47 -0
  2. package/dist/AbstractApiData-XLhBP5Tl.d.mts +47 -0
  3. package/dist/{BlockNoteEditor-G4XNDV2O.mjs → BlockNoteEditor-FIMPVA7K.mjs} +5 -5
  4. package/dist/{BlockNoteEditor-3IZ7PMFM.js → BlockNoteEditor-GJPTMKS7.js} +20 -20
  5. package/dist/{BlockNoteEditor-3IZ7PMFM.js.map → BlockNoteEditor-GJPTMKS7.js.map} +1 -1
  6. package/dist/{JsonApiRequest-3UWVIIFL.mjs → JsonApiRequest-2OQCZAK6.mjs} +2 -2
  7. package/dist/JsonApiRequest-6TYNBVUL.js +26 -0
  8. package/dist/{JsonApiRequest-QRU3IPIU.js.map → JsonApiRequest-6TYNBVUL.js.map} +1 -1
  9. package/dist/ai-connection.module-28f-hux0.d.mts +255 -0
  10. package/dist/ai-connection.module-Bqwh_DnR.d.ts +255 -0
  11. package/dist/billing/index.js +399 -399
  12. package/dist/billing/index.mjs +4 -4
  13. package/dist/{chunk-TWXKAY34.mjs → chunk-CGJW7ZZM.mjs} +310 -5
  14. package/dist/chunk-CGJW7ZZM.mjs.map +1 -0
  15. package/dist/{chunk-E5YA5Z5X.mjs → chunk-CMGXH5SA.mjs} +2 -1
  16. package/dist/chunk-CMGXH5SA.mjs.map +1 -0
  17. package/dist/{chunk-5GVBUTS4.js → chunk-E5FWGOP5.js} +1454 -794
  18. package/dist/chunk-E5FWGOP5.js.map +1 -0
  19. package/dist/{chunk-JKGEJGSD.js → chunk-H7DOSB7W.js} +9 -9
  20. package/dist/{chunk-JKGEJGSD.js.map → chunk-H7DOSB7W.js.map} +1 -1
  21. package/dist/{chunk-FPO4DLZN.js → chunk-PFAZPP54.js} +2 -1
  22. package/dist/chunk-PFAZPP54.js.map +1 -0
  23. package/dist/{chunk-WCX4CLRJ.mjs → chunk-VUMM7V5G.mjs} +1740 -1080
  24. package/dist/chunk-VUMM7V5G.mjs.map +1 -0
  25. package/dist/{chunk-53B6NPGA.js → chunk-YUXCJ35V.js} +377 -72
  26. package/dist/chunk-YUXCJ35V.js.map +1 -0
  27. package/dist/{chunk-PHEFWL6L.mjs → chunk-ZMYZFQN3.mjs} +3 -3
  28. package/dist/client/index.js +5 -5
  29. package/dist/client/index.mjs +4 -4
  30. package/dist/components/index.d.mts +122 -4
  31. package/dist/components/index.d.ts +122 -4
  32. package/dist/components/index.js +31 -5
  33. package/dist/components/index.js.map +1 -1
  34. package/dist/components/index.mjs +30 -4
  35. package/dist/{config-BIjrg5wd.d.ts → config-B6UB7j69.d.ts} +1 -46
  36. package/dist/{config-BRUjtCd1.d.mts → config-n-ZpPmOL.d.mts} +1 -46
  37. package/dist/contexts/index.js +5 -5
  38. package/dist/contexts/index.mjs +4 -4
  39. package/dist/core/index.d.mts +5 -4
  40. package/dist/core/index.d.ts +5 -4
  41. package/dist/core/index.js +11 -3
  42. package/dist/core/index.js.map +1 -1
  43. package/dist/core/index.mjs +10 -2
  44. package/dist/features/help/index.js +38 -38
  45. package/dist/features/help/index.mjs +4 -4
  46. package/dist/features/tokenusage/index.d.mts +2 -2
  47. package/dist/features/tokenusage/index.d.ts +2 -2
  48. package/dist/features/tokenusage/index.js +102 -102
  49. package/dist/features/tokenusage/index.mjs +4 -4
  50. package/dist/index.d.mts +3 -3
  51. package/dist/index.d.ts +3 -3
  52. package/dist/index.js +12 -4
  53. package/dist/index.js.map +1 -1
  54. package/dist/index.mjs +11 -3
  55. package/dist/server/index.js +12 -12
  56. package/dist/server/index.mjs +2 -2
  57. package/package.json +1 -1
  58. package/src/components/index.ts +1 -0
  59. package/src/components/tables/ContentListTable.tsx +1 -1
  60. package/src/core/index.ts +5 -0
  61. package/src/core/registry/ModuleRegistry.ts +2 -0
  62. package/src/core/utils/translateResponse.ts +6 -0
  63. package/src/features/administration/components/AdminIndexContainer.tsx +9 -2
  64. package/src/features/ai-connection/ai-connection.module.ts +14 -0
  65. package/src/features/ai-connection/components/containers/AiConnectionsContainer.tsx +109 -0
  66. package/src/features/ai-connection/components/forms/AiConnectionDeleter.tsx +58 -0
  67. package/src/features/ai-connection/components/forms/AiConnectionEditor.tsx +329 -0
  68. package/src/features/ai-connection/components/lists/AiConnectionTypeCard.tsx +223 -0
  69. package/src/features/ai-connection/contexts/AiConnectionsContext.tsx +111 -0
  70. package/src/features/ai-connection/data/__tests__/ai-connection.test.ts +202 -0
  71. package/src/features/ai-connection/data/ai-connection.fields.ts +6 -0
  72. package/src/features/ai-connection/data/ai-connection.interface.ts +87 -0
  73. package/src/features/ai-connection/data/ai-connection.service.ts +90 -0
  74. package/src/features/ai-connection/data/ai-connection.ts +244 -0
  75. package/src/features/ai-connection/data/index.ts +4 -0
  76. package/src/features/ai-connection/i18n-keys.ts +66 -0
  77. package/src/features/ai-connection/index.ts +11 -0
  78. package/src/features/index.ts +1 -0
  79. package/dist/AuthComponent-Cik96ElA.d.mts +0 -56
  80. package/dist/AuthComponent-DTTKu3VK.d.ts +0 -56
  81. package/dist/JsonApiRequest-QRU3IPIU.js +0 -26
  82. package/dist/chunk-53B6NPGA.js.map +0 -1
  83. package/dist/chunk-5GVBUTS4.js.map +0 -1
  84. package/dist/chunk-E5YA5Z5X.mjs.map +0 -1
  85. package/dist/chunk-FPO4DLZN.js.map +0 -1
  86. package/dist/chunk-TWXKAY34.mjs.map +0 -1
  87. package/dist/chunk-WCX4CLRJ.mjs.map +0 -1
  88. /package/dist/{BlockNoteEditor-G4XNDV2O.mjs.map → BlockNoteEditor-FIMPVA7K.mjs.map} +0 -0
  89. /package/dist/{JsonApiRequest-3UWVIIFL.mjs.map → JsonApiRequest-2OQCZAK6.mjs.map} +0 -0
  90. /package/dist/{chunk-PHEFWL6L.mjs.map → chunk-ZMYZFQN3.mjs.map} +0 -0
@@ -0,0 +1,329 @@
1
+ "use client";
2
+
3
+ import { zodResolver } from "@hookform/resolvers/zod";
4
+ import { useTranslations } from "next-intl";
5
+ import { ReactNode, useCallback, useEffect, useMemo } from "react";
6
+ import { useForm } from "react-hook-form";
7
+ import { v4 } from "uuid";
8
+ import { z } from "zod";
9
+ import { EditorSheet, FormCheckbox, FormInput, FormPassword, FormSelect, MicroLabel } from "../../../../components";
10
+ import { Modules } from "../../../../core";
11
+ import { Alert, AlertDescription, AlertTitle } from "../../../../shadcnui";
12
+ import { AiConnectionInput, AiConnectionInterface, AiConnectionService, AiProviderFieldDescriptor } from "../../data";
13
+ import { useAiConnections } from "../../contexts/AiConnectionsContext";
14
+
15
+ /** Fields grouped under the collapsed-by-convention cost block (spec § 4). */
16
+ const COST_FIELDS = [
17
+ "inputCostPer1MTokens",
18
+ "outputCostPer1MTokens",
19
+ "cachedInputCostPer1MTokens",
20
+ "costPerMinute",
21
+ "costPerPage",
22
+ ];
23
+
24
+ type AiConnectionFormValues = {
25
+ id: string;
26
+ name: string;
27
+ provider: string;
28
+ enabled: boolean;
29
+ /** Every provider-specific field, keyed by the registry's field id. */
30
+ values: Record<string, any>;
31
+ };
32
+
33
+ export type AiConnectionEditorProps = {
34
+ /** Omitted in create mode. */
35
+ connection?: AiConnectionInterface;
36
+ /** Fixed by the card that owns the editor — never editable. */
37
+ connectionType: string;
38
+ /** Slot in the chain. Fixed: reordering is the up/down arrows' job. */
39
+ position: number;
40
+ /** Scope, set on creation only — immutable thereafter (spec § Decisions). */
41
+ companyId?: string;
42
+ trigger?: ReactNode;
43
+ forceShow?: boolean;
44
+ onClose?: () => void;
45
+ };
46
+
47
+ /**
48
+ * Schema-driven editor for one link in a fallback chain.
49
+ *
50
+ * The static half of the form (name / provider / enabled) is a fixed zod shape;
51
+ * everything else is rendered blind from `meta.providerRegistry`, so a new
52
+ * provider field on the backend needs no change here — only an i18n string.
53
+ * Required-ness is enforced by a `superRefine` over the same registry, which
54
+ * keeps failures inline on the field instead of an error toast. The backend
55
+ * re-validates authoritatively.
56
+ */
57
+ export function AiConnectionEditor({
58
+ connection,
59
+ connectionType,
60
+ position,
61
+ companyId,
62
+ trigger,
63
+ forceShow,
64
+ onClose,
65
+ }: AiConnectionEditorProps) {
66
+ const t = useTranslations();
67
+ const { providerRegistry, refresh } = useAiConnections();
68
+ const isEdit = !!connection;
69
+
70
+ const registryRows = useMemo(() => providerRegistry[connectionType] ?? [], [providerRegistry, connectionType]);
71
+ const providers = useMemo(() => registryRows.map((row) => row.provider), [registryRows]);
72
+
73
+ const fieldsFor = useCallback(
74
+ (provider: string): AiProviderFieldDescriptor[] =>
75
+ registryRows.find((row) => row.provider === provider)?.fields ?? [],
76
+ [registryRows],
77
+ );
78
+
79
+ /** Seeds the `values` map for a provider: stored value → registry default → empty. */
80
+ const defaultsFor = useCallback(
81
+ (provider: string): Record<string, any> => {
82
+ const stored = connection as unknown as Record<string, unknown> | undefined;
83
+ const seeded: Record<string, any> = {};
84
+
85
+ fieldsFor(provider).forEach((descriptor) => {
86
+ if (descriptor.kind === "boolean") {
87
+ const current = stored?.[descriptor.field];
88
+ seeded[descriptor.field] =
89
+ current !== undefined && current !== null ? current === true : descriptor.default === true;
90
+ return;
91
+ }
92
+
93
+ // Secrets are write-only: the API answers with a has-flag, never a value.
94
+ if (descriptor.kind === "secret") {
95
+ seeded[descriptor.field] = "";
96
+ return;
97
+ }
98
+
99
+ const current = stored?.[descriptor.field];
100
+ if (current !== undefined && current !== null) seeded[descriptor.field] = String(current);
101
+ else seeded[descriptor.field] = descriptor.default !== undefined ? String(descriptor.default) : "";
102
+ });
103
+
104
+ return seeded;
105
+ },
106
+ [connection, fieldsFor],
107
+ );
108
+
109
+ const hasStoredSecret = useCallback(
110
+ (field: string) => {
111
+ if (!connection) return false;
112
+ if (field === "apiKey") return connection.hasApiKey;
113
+ if (field === "googleCredentialsBase64") return connection.hasGoogleCredentials;
114
+ return false;
115
+ },
116
+ [connection],
117
+ );
118
+
119
+ const formSchema = useMemo(
120
+ () =>
121
+ z
122
+ .object({
123
+ id: z.uuidv4(),
124
+ name: z.string().min(1, { message: t("ai_connections.admin.errors.name") }),
125
+ provider: z.string().min(1, { message: t("ai_connections.admin.errors.required_field") }),
126
+ enabled: z.boolean(),
127
+ values: z.record(z.string(), z.any()),
128
+ })
129
+ .superRefine((data, ctx) => {
130
+ fieldsFor(data.provider)
131
+ .filter((descriptor) => descriptor.required)
132
+ .forEach((descriptor) => {
133
+ const value = data.values?.[descriptor.field];
134
+ // A stored secret counts as filled: blank means "keep it".
135
+ const filled =
136
+ descriptor.kind === "secret"
137
+ ? !!value || hasStoredSecret(descriptor.field)
138
+ : descriptor.kind === "boolean"
139
+ ? value !== undefined
140
+ : value !== undefined && value !== null && String(value).trim() !== "";
141
+ if (filled) return;
142
+ ctx.addIssue({
143
+ code: "custom",
144
+ message: t("ai_connections.admin.errors.required_field"),
145
+ path: ["values", descriptor.field],
146
+ });
147
+ });
148
+ }),
149
+ [t, fieldsFor, hasStoredSecret],
150
+ );
151
+
152
+ const getDefaultValues = useCallback(
153
+ (): AiConnectionFormValues => ({
154
+ id: connection?.id ?? v4(),
155
+ name: connection?.name ?? "",
156
+ provider: connection?.provider ?? providers[0] ?? "",
157
+ enabled: connection?.enabled ?? true,
158
+ values: defaultsFor(connection?.provider ?? providers[0] ?? ""),
159
+ }),
160
+ [connection, providers, defaultsFor],
161
+ );
162
+
163
+ const form = useForm<AiConnectionFormValues>({
164
+ resolver: zodResolver(formSchema),
165
+ defaultValues: getDefaultValues(),
166
+ });
167
+
168
+ // The registry arrives with the list request, which can resolve after this
169
+ // component mounts — so the provider and its field map are seeded again once
170
+ // it is available. Pre-filling via setValue is the supported create-mode
171
+ // pattern (EditorSheet re-seeds only on edit; see EditorSheet.tsx:163-190).
172
+ useEffect(() => {
173
+ if (registryRows.length === 0) return;
174
+
175
+ const currentProvider = form.getValues("provider");
176
+ const provider = currentProvider || connection?.provider || providers[0] || "";
177
+ if (!provider) return;
178
+ if (provider !== currentProvider) form.setValue("provider", provider);
179
+
180
+ const currentValues = form.getValues("values");
181
+ if (!currentValues || Object.keys(currentValues).length === 0) form.setValue("values", defaultsFor(provider));
182
+ // Deliberately keyed on the registry alone: re-running on `form` identity
183
+ // would clobber what the user has typed.
184
+ }, [registryRows]);
185
+
186
+ const selectedProvider = form.watch("provider");
187
+ const selectedFields = fieldsFor(selectedProvider);
188
+ const configFields = selectedFields.filter((descriptor) => !COST_FIELDS.includes(descriptor.field));
189
+ const costFields = selectedFields.filter((descriptor) => COST_FIELDS.includes(descriptor.field));
190
+
191
+ /** Registry value → wire value. Numbers arrive from the input as strings. */
192
+ const coerce = (descriptor: AiProviderFieldDescriptor, raw: any) => {
193
+ if (descriptor.kind === "boolean") return raw === true;
194
+ if (raw === undefined || raw === null || String(raw).trim() === "") return undefined;
195
+ if (descriptor.kind === "number") {
196
+ const parsed = Number(String(raw).replace(",", "."));
197
+ return Number.isFinite(parsed) ? parsed : undefined;
198
+ }
199
+ return String(raw);
200
+ };
201
+
202
+ const renderField = (descriptor: AiProviderFieldDescriptor) => {
203
+ const id = `values.${descriptor.field}`;
204
+ const label = t(`ai_connections.admin.fields.${descriptor.field}`);
205
+
206
+ switch (descriptor.kind) {
207
+ case "secret":
208
+ return (
209
+ <FormPassword
210
+ key={descriptor.field}
211
+ form={form}
212
+ id={id}
213
+ name={label}
214
+ isRequired={descriptor.required}
215
+ placeholder={
216
+ isEdit && hasStoredSecret(descriptor.field)
217
+ ? t("ai_connections.admin.placeholders.secret_unchanged")
218
+ : undefined
219
+ }
220
+ />
221
+ );
222
+ case "boolean":
223
+ return <FormCheckbox key={descriptor.field} form={form} id={id} name={label} />;
224
+ case "select":
225
+ return (
226
+ <FormSelect
227
+ key={descriptor.field}
228
+ form={form}
229
+ id={id}
230
+ name={label}
231
+ isRequired={descriptor.required}
232
+ allowEmpty={!descriptor.required}
233
+ values={(descriptor.options ?? []).map((option) => ({ id: option, text: option }))}
234
+ />
235
+ );
236
+ case "number":
237
+ // "decimal", not "number": FormInput's number mode strips everything but
238
+ // digits, which would make a cost of 0.15 unrepresentable.
239
+ return (
240
+ <FormInput
241
+ key={descriptor.field}
242
+ form={form}
243
+ id={id}
244
+ name={label}
245
+ type="decimal"
246
+ isRequired={descriptor.required}
247
+ />
248
+ );
249
+ default:
250
+ return <FormInput key={descriptor.field} form={form} id={id} name={label} isRequired={descriptor.required} />;
251
+ }
252
+ };
253
+
254
+ return (
255
+ <EditorSheet
256
+ form={form}
257
+ entityType={t("ai_connections.admin.entity")}
258
+ entityName={connection?.name}
259
+ isEdit={isEdit}
260
+ module={Modules.AiConnection}
261
+ size="md"
262
+ onSuccess={refresh}
263
+ onSubmit={async (values) => {
264
+ const attributes: Record<string, unknown> = {};
265
+ fieldsFor(values.provider).forEach((descriptor) => {
266
+ const coerced = coerce(descriptor, values.values?.[descriptor.field]);
267
+ if (coerced !== undefined) attributes[descriptor.field] = coerced;
268
+ });
269
+
270
+ const payload = {
271
+ id: values.id,
272
+ name: values.name,
273
+ connectionType,
274
+ provider: values.provider,
275
+ position,
276
+ enabled: values.enabled,
277
+ ...attributes,
278
+ // Scope is fixed at creation; sending it on update would be a no-op at
279
+ // best and a scope change at worst, which the design forbids.
280
+ ...(isEdit || !companyId ? {} : { companyId }),
281
+ } as AiConnectionInput;
282
+
283
+ if (isEdit) await AiConnectionService.update(payload);
284
+ else await AiConnectionService.create(payload);
285
+ }}
286
+ onReset={getDefaultValues}
287
+ trigger={trigger}
288
+ forceShow={forceShow}
289
+ onClose={onClose}
290
+ >
291
+ <div className="flex w-full flex-col gap-y-4">
292
+ {connectionType === "embedder" && (
293
+ <Alert variant="destructive">
294
+ <AlertTitle>{t("ai_connections.admin.embedder_warning.title")}</AlertTitle>
295
+ <AlertDescription>{t("ai_connections.admin.embedder_warning.description")}</AlertDescription>
296
+ </Alert>
297
+ )}
298
+
299
+ <FormInput
300
+ form={form}
301
+ id="name"
302
+ name={t("ai_connections.admin.fields.name")}
303
+ placeholder={t("ai_connections.admin.placeholders.name")}
304
+ isRequired
305
+ />
306
+
307
+ <FormSelect
308
+ form={form}
309
+ id="provider"
310
+ name={t("ai_connections.admin.fields.provider")}
311
+ isRequired
312
+ values={providers.map((provider) => ({ id: provider, text: provider }))}
313
+ onChange={(provider) => form.setValue("values", defaultsFor(provider))}
314
+ />
315
+
316
+ <FormCheckbox form={form} id="enabled" name={t("ai_connections.admin.fields.enabled")} />
317
+
318
+ {configFields.map(renderField)}
319
+
320
+ {costFields.length > 0 && (
321
+ <div className="flex w-full flex-col gap-y-4">
322
+ <MicroLabel>{t("ai_connections.admin.fields.costs_section")}</MicroLabel>
323
+ {costFields.map(renderField)}
324
+ </div>
325
+ )}
326
+ </div>
327
+ </EditorSheet>
328
+ );
329
+ }
@@ -0,0 +1,223 @@
1
+ "use client";
2
+
3
+ import { ChevronDownIcon, ChevronUpIcon, PlusIcon } from "lucide-react";
4
+ import { useTranslations } from "next-intl";
5
+ import { useMemo } from "react";
6
+ import { SectionHeader } from "../../../../components";
7
+ import { errorToast } from "../../../../components/errors";
8
+ import { Badge, Button, Card, CardAction, CardContent, CardHeader, EmptyState, Switch } from "../../../../shadcnui";
9
+ import { cn } from "../../../../utils";
10
+ import { AiConnectionInput, AiConnectionInterface, AiConnectionService } from "../../data";
11
+ import { useAiConnections } from "../../contexts/AiConnectionsContext";
12
+ import { AiConnectionDeleter } from "../forms/AiConnectionDeleter";
13
+ import { AiConnectionEditor } from "../forms/AiConnectionEditor";
14
+
15
+ /**
16
+ * Rebuilds the full write payload for an existing connection.
17
+ *
18
+ * Every mutation goes through the model (AiConnectionService.update →
19
+ * AiConnection.createJsonApi), so an inline toggle has to resend the row's
20
+ * current attributes rather than patch a single one. Secrets are deliberately
21
+ * absent: an omitted secret means "keep the stored value" (spec § Decisions),
22
+ * and the API never serialises them back in the first place.
23
+ */
24
+ function toInput(connection: AiConnectionInterface, overrides: Partial<AiConnectionInput>): AiConnectionInput {
25
+ return {
26
+ id: connection.id,
27
+ name: connection.name,
28
+ connectionType: connection.connectionType,
29
+ provider: connection.provider,
30
+ position: connection.position,
31
+ enabled: connection.enabled,
32
+ model: connection.model,
33
+ url: connection.url,
34
+ region: connection.region,
35
+ instance: connection.instance,
36
+ apiVersion: connection.apiVersion,
37
+ allowFallbacks: connection.allowFallbacks,
38
+ reasoningEffort: connection.reasoningEffort,
39
+ maxOutputTokens: connection.maxOutputTokens,
40
+ dimensions: connection.dimensions,
41
+ inputCostPer1MTokens: connection.inputCostPer1MTokens,
42
+ outputCostPer1MTokens: connection.outputCostPer1MTokens,
43
+ cachedInputCostPer1MTokens: connection.cachedInputCostPer1MTokens,
44
+ costPerMinute: connection.costPerMinute,
45
+ costPerPage: connection.costPerPage,
46
+ directUrl: connection.directUrl,
47
+ language: connection.language,
48
+ directFormat: connection.directFormat,
49
+ directProvider: connection.directProvider,
50
+ ...overrides,
51
+ };
52
+ }
53
+
54
+ export type AiConnectionTypeCardProps = {
55
+ /** One of the eight AI connection types — also the card's heading key. */
56
+ connectionType: string;
57
+ };
58
+
59
+ /**
60
+ * One fallback chain, for one connection type, in the currently selected scope.
61
+ *
62
+ * The chain always ends with a read-only ".env fallback" row: the env block is
63
+ * the last candidate the resolver tries, so showing it keeps the page an honest
64
+ * picture of what a call will actually do (spec § 4).
65
+ */
66
+ export function AiConnectionTypeCard({ connectionType }: AiConnectionTypeCardProps) {
67
+ const t = useTranslations();
68
+ const { connections, envDefaults, scopeCompanyId, refresh } = useAiConnections();
69
+
70
+ const rows = useMemo(
71
+ () =>
72
+ connections
73
+ .filter(
74
+ (connection) =>
75
+ connection.connectionType === connectionType &&
76
+ (scopeCompanyId ? connection.companyId === scopeCompanyId : !connection.companyId),
77
+ )
78
+ .sort((a, b) => a.position - b.position),
79
+ [connections, connectionType, scopeCompanyId],
80
+ );
81
+
82
+ // Shown greyed-out under the "inheriting global chain" hint when a company
83
+ // scope has no chain of its own for this type.
84
+ const globalRows = useMemo(
85
+ () =>
86
+ connections
87
+ .filter((connection) => connection.connectionType === connectionType && !connection.companyId)
88
+ .sort((a, b) => a.position - b.position),
89
+ [connections, connectionType],
90
+ );
91
+
92
+ const inheritsGlobal = !!scopeCompanyId && rows.length === 0;
93
+ const envDefault = envDefaults[connectionType];
94
+ const typeLabel = t(`ai_connections.admin.types.${connectionType}`);
95
+
96
+ const setEnabled = async (connection: AiConnectionInterface, enabled: boolean) => {
97
+ try {
98
+ await AiConnectionService.update(toInput(connection, { enabled }));
99
+ await refresh();
100
+ } catch (error) {
101
+ errorToast({ title: typeLabel, error });
102
+ }
103
+ };
104
+
105
+ /** Moves a row by one slot and renumbers the whole chain server-side. */
106
+ const move = async (index: number, delta: number) => {
107
+ const target = index + delta;
108
+ if (target < 0 || target >= rows.length) return;
109
+ const reordered = [...rows];
110
+ const [moved] = reordered.splice(index, 1);
111
+ reordered.splice(target, 0, moved);
112
+ try {
113
+ await AiConnectionService.reorder({ ids: reordered.map((connection) => connection.id) });
114
+ await refresh();
115
+ } catch (error) {
116
+ errorToast({ title: typeLabel, error });
117
+ }
118
+ };
119
+
120
+ const renderRow = (connection: AiConnectionInterface, index: number, readOnly: boolean, total: number) => (
121
+ <div
122
+ key={connection.id}
123
+ className={cn("flex items-center gap-3 px-3 py-2", readOnly && "opacity-50")}
124
+ data-testid={`ai-connection-row-${connection.id}`}
125
+ >
126
+ <span className="text-muted-foreground w-5 text-right text-xs tabular-nums">{index + 1}</span>
127
+ <div className="flex min-w-0 flex-1 flex-col">
128
+ <span className="truncate text-sm">{connection.name}</span>
129
+ <span className="text-muted-foreground truncate text-xs">
130
+ {[connection.provider, connection.model].filter(Boolean).join(" · ")}
131
+ </span>
132
+ </div>
133
+ <Switch
134
+ checked={connection.enabled}
135
+ disabled={readOnly}
136
+ aria-label={t("ai_connections.admin.fields.enabled")}
137
+ onCheckedChange={(checked) => {
138
+ if (!readOnly) void setEnabled(connection, checked === true);
139
+ }}
140
+ />
141
+ <Button
142
+ variant="ghost"
143
+ size="icon-sm"
144
+ className="text-muted-foreground"
145
+ aria-label={t("ai_connections.admin.reorder.up")}
146
+ disabled={readOnly || index === 0}
147
+ onClick={() => void move(index, -1)}
148
+ >
149
+ <ChevronUpIcon />
150
+ </Button>
151
+ <Button
152
+ variant="ghost"
153
+ size="icon-sm"
154
+ className="text-muted-foreground"
155
+ aria-label={t("ai_connections.admin.reorder.down")}
156
+ disabled={readOnly || index === total - 1}
157
+ onClick={() => void move(index, 1)}
158
+ >
159
+ <ChevronDownIcon />
160
+ </Button>
161
+ {!readOnly && (
162
+ <>
163
+ <AiConnectionEditor
164
+ connection={connection}
165
+ connectionType={connectionType}
166
+ position={connection.position}
167
+ companyId={connection.companyId}
168
+ />
169
+ <AiConnectionDeleter connection={connection} />
170
+ </>
171
+ )}
172
+ </div>
173
+ );
174
+
175
+ return (
176
+ <Card>
177
+ <CardHeader>
178
+ <SectionHeader>{typeLabel}</SectionHeader>
179
+ <CardAction>
180
+ <AiConnectionEditor
181
+ connectionType={connectionType}
182
+ position={rows.length}
183
+ companyId={scopeCompanyId ?? undefined}
184
+ trigger={
185
+ <Button variant="outline" size="sm">
186
+ <PlusIcon />
187
+ {t("ai_connections.admin.create")}
188
+ </Button>
189
+ }
190
+ />
191
+ </CardAction>
192
+ </CardHeader>
193
+ <CardContent>
194
+ {inheritsGlobal && (
195
+ <p className="text-muted-foreground mb-2 text-xs">{t("ai_connections.admin.scope.inherits_global")}</p>
196
+ )}
197
+
198
+ <div className="divide-border flex flex-col divide-y rounded-md border">
199
+ {inheritsGlobal
200
+ ? globalRows.map((connection, index) => renderRow(connection, index, true, globalRows.length))
201
+ : rows.map((connection, index) => renderRow(connection, index, false, rows.length))}
202
+
203
+ {!inheritsGlobal && rows.length === 0 && (
204
+ <EmptyState
205
+ className="p-3"
206
+ title={t("ai_connections.admin.empty.title")}
207
+ description={t("ai_connections.admin.empty.description")}
208
+ />
209
+ )}
210
+
211
+ {/* The env block is always the last candidate the resolver tries, so
212
+ it always closes the chain — read-only, with no controls. */}
213
+ <div className="bg-muted/40 flex flex-wrap items-center gap-2 px-3 py-2">
214
+ <Badge variant="softGray">{t("ai_connections.admin.env_fallback")}</Badge>
215
+ <span className="text-muted-foreground truncate text-xs">
216
+ {[envDefault?.provider, envDefault?.model, envDefault?.url].filter(Boolean).join(" · ")}
217
+ </span>
218
+ </div>
219
+ </div>
220
+ </CardContent>
221
+ </Card>
222
+ );
223
+ }
@@ -0,0 +1,111 @@
1
+ "use client";
2
+
3
+ import { useTranslations } from "next-intl";
4
+ import { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useState } from "react";
5
+ import { SharedProvider } from "../../../contexts";
6
+ import { usePageUrlGenerator } from "../../../hooks";
7
+ import { BreadcrumbItemData } from "../../../interfaces";
8
+ import { AiConnectionEnvDefaults, AiConnectionInterface, AiConnectionService, AiProviderRegistry } from "../data";
9
+
10
+ /**
11
+ * Route the breadcrumb links back to. Constant rather than a prop for the same
12
+ * reason TOKEN_USAGE_ADMIN_PAGE_URL is
13
+ * (features/tokenusage/contexts/TokenUsageAdminContext.tsx): the breadcrumb is
14
+ * built here, not by the consuming app, and the path convention IS the contract.
15
+ */
16
+ export const AI_CONNECTIONS_ADMIN_PAGE_URL = "/administration/ai-connections";
17
+
18
+ export interface AiConnectionsContextType {
19
+ /** Every connection, both scopes — the cards filter per scope themselves. */
20
+ connections: AiConnectionInterface[];
21
+ /** `meta.providerRegistry` from the list endpoint: connectionType → provider rows. */
22
+ providerRegistry: AiProviderRegistry;
23
+ /** `meta.envDefaults` from the list endpoint: the read-only final chain link. */
24
+ envDefaults: AiConnectionEnvDefaults;
25
+ /** `null` = the global chain; a company id = that company's chain. */
26
+ scopeCompanyId: string | null;
27
+ setScopeCompanyId: (companyId: string | null) => void;
28
+ /** Re-reads the whole list. Called after every mutation. */
29
+ refresh: () => Promise<void>;
30
+ isLoading: boolean;
31
+ }
32
+
33
+ const defaultContextValue: AiConnectionsContextType = {
34
+ connections: [],
35
+ providerRegistry: {},
36
+ envDefaults: {},
37
+ scopeCompanyId: null,
38
+ setScopeCompanyId: () => {},
39
+ refresh: async () => {},
40
+ isLoading: false,
41
+ };
42
+
43
+ const AiConnectionsContext = createContext<AiConnectionsContextType | undefined>(undefined);
44
+
45
+ export type AiConnectionsProviderProps = {
46
+ children: ReactNode;
47
+ };
48
+
49
+ /**
50
+ * Owns the single list request behind the AI-connections admin page and the
51
+ * scope the page is looking at.
52
+ *
53
+ * It also publishes the page chrome, exactly as TokenUsageAdminContext does:
54
+ * RoundPageContainerTitle and the header breadcrumb read `useSharedContext()`,
55
+ * and that value is supplied far up the tree — a page that publishes nothing
56
+ * silently inherits the previous page's title.
57
+ */
58
+ export const AiConnectionsProvider = ({ children }: AiConnectionsProviderProps) => {
59
+ const t = useTranslations();
60
+ const generateUrl = usePageUrlGenerator();
61
+
62
+ const [connections, setConnections] = useState<AiConnectionInterface[]>([]);
63
+ const [providerRegistry, setProviderRegistry] = useState<AiProviderRegistry>({});
64
+ const [envDefaults, setEnvDefaults] = useState<AiConnectionEnvDefaults>({});
65
+ const [scopeCompanyId, setScopeCompanyId] = useState<string | null>(null);
66
+ const [isLoading, setIsLoading] = useState<boolean>(true);
67
+
68
+ const refresh = useCallback(async () => {
69
+ setIsLoading(true);
70
+ try {
71
+ const response = await AiConnectionService.listConnections();
72
+ setConnections(response.connections);
73
+ setProviderRegistry(response.providerRegistry);
74
+ setEnvDefaults(response.envDefaults);
75
+ } finally {
76
+ setIsLoading(false);
77
+ }
78
+ }, []);
79
+
80
+ useEffect(() => {
81
+ void refresh();
82
+ }, [refresh]);
83
+
84
+ const value = useMemo<AiConnectionsContextType>(
85
+ () => ({
86
+ connections,
87
+ providerRegistry,
88
+ envDefaults,
89
+ scopeCompanyId,
90
+ setScopeCompanyId,
91
+ refresh,
92
+ isLoading,
93
+ }),
94
+ [connections, providerRegistry, envDefaults, scopeCompanyId, refresh, isLoading],
95
+ );
96
+
97
+ const breadcrumbs: BreadcrumbItemData[] = [
98
+ {
99
+ name: t("ai_connections.admin.title"),
100
+ href: generateUrl({ page: AI_CONNECTIONS_ADMIN_PAGE_URL }),
101
+ },
102
+ ];
103
+
104
+ return (
105
+ <SharedProvider value={{ breadcrumbs, title: { type: t("ai_connections.admin.title") } }}>
106
+ <AiConnectionsContext.Provider value={value}>{children}</AiConnectionsContext.Provider>
107
+ </SharedProvider>
108
+ );
109
+ };
110
+
111
+ export const useAiConnections = (): AiConnectionsContextType => useContext(AiConnectionsContext) ?? defaultContextValue;