@carlonicora/nextjs-jsonapi 2.2.3 → 2.4.0
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.
- package/dist/{BlockNoteEditor-QTIDATPF.js → BlockNoteEditor-7WGCH2KB.js} +27 -26
- package/dist/BlockNoteEditor-7WGCH2KB.js.map +1 -0
- package/dist/{BlockNoteEditor-ADT6II2V.mjs → BlockNoteEditor-GWAV5ESO.mjs} +12 -11
- package/dist/BlockNoteEditor-GWAV5ESO.mjs.map +1 -0
- package/dist/billing/index.d.mts +40 -1
- package/dist/billing/index.d.ts +40 -1
- package/dist/billing/index.js +519 -462
- package/dist/billing/index.js.map +1 -1
- package/dist/billing/index.mjs +298 -241
- package/dist/billing/index.mjs.map +1 -1
- package/dist/{chunk-7DJOQTNN.mjs → chunk-3LVSA7IM.mjs} +2 -2
- package/dist/{chunk-F3BPNQDB.js → chunk-GYGMEDVS.js} +1135 -952
- package/dist/chunk-GYGMEDVS.js.map +1 -0
- package/dist/{chunk-DLTMYGLR.mjs → chunk-LCCRUWWY.mjs} +13 -1
- package/dist/chunk-LCCRUWWY.mjs.map +1 -0
- package/dist/{chunk-YEPE2HT6.mjs → chunk-M2EGUO2F.mjs} +2538 -2355
- package/dist/chunk-M2EGUO2F.mjs.map +1 -0
- package/dist/{chunk-LANOKEFZ.js → chunk-TOKHHZSP.js} +7 -7
- package/dist/{chunk-LANOKEFZ.js.map → chunk-TOKHHZSP.js.map} +1 -1
- package/dist/{chunk-KSU6CCW6.js → chunk-V66AZWPG.js} +14 -2
- package/dist/chunk-V66AZWPG.js.map +1 -0
- package/dist/client/index.d.mts +1 -1
- package/dist/client/index.d.ts +1 -1
- package/dist/client/index.js +4 -4
- package/dist/client/index.mjs +3 -3
- package/dist/components/index.d.mts +129 -4
- package/dist/components/index.d.ts +129 -4
- package/dist/components/index.js +16 -4
- package/dist/components/index.js.map +1 -1
- package/dist/components/index.mjs +15 -3
- package/dist/{content.fields-xH3TGvVk.d.mts → content.fields-1AlHDtDb.d.mts} +1 -0
- package/dist/{content.fields-xH3TGvVk.d.ts → content.fields-1AlHDtDb.d.ts} +1 -0
- package/dist/contexts/index.d.mts +22 -1
- package/dist/contexts/index.d.ts +22 -1
- package/dist/contexts/index.js +8 -4
- package/dist/contexts/index.js.map +1 -1
- package/dist/contexts/index.mjs +7 -3
- package/dist/core/index.d.mts +12 -2
- package/dist/core/index.d.ts +12 -2
- package/dist/core/index.js +4 -2
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +3 -1
- package/dist/features/help/index.js +37 -37
- package/dist/features/help/index.mjs +3 -3
- package/dist/features/tokenusage/index.js +71 -71
- package/dist/features/tokenusage/index.mjs +3 -3
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +5 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +4 -2
- package/dist/server/index.js +3 -3
- package/dist/server/index.mjs +1 -1
- package/package.json +1 -1
- package/src/billing/index.ts +1 -0
- package/src/components/editors/BlockNoteEditor.tsx +12 -6
- package/src/components/index.ts +1 -0
- package/src/contexts/index.ts +1 -0
- package/src/features/administration/components/AdminIndexContainer.tsx +150 -0
- package/src/features/administration/components/__tests__/AdminIndexContainer.spec.tsx +96 -0
- package/src/features/administration/components/index.ts +1 -0
- package/src/features/administration/contexts/AdministrationContext.tsx +47 -0
- package/src/features/administration/contexts/index.ts +1 -0
- package/src/features/administration/data/admin-section.types.ts +30 -0
- package/src/features/administration/i18n-keys.ts +26 -0
- package/src/features/administration/index.ts +3 -0
- package/src/features/assistant/components/containers/AssistantPageContainer.tsx +8 -1
- package/src/features/billing/contexts/BillingContext.tsx +95 -0
- package/src/features/billing/contexts/index.ts +1 -0
- package/src/features/billing/index.ts +1 -0
- package/src/features/billing/stripe-product/components/containers/ProductsAdminContainer.tsx +39 -27
- package/src/features/billing/stripe-product/components/containers/ProductsAdminPageContainer.tsx +27 -0
- package/src/features/billing/stripe-product/components/containers/index.ts +1 -0
- package/src/features/company/components/containers/CompaniesListContainer.tsx +25 -0
- package/src/features/company/components/containers/index.ts +1 -0
- package/src/features/company/components/lists/CompaniesList.tsx +12 -1
- package/src/features/company/contexts/CompanyContext.tsx +13 -1
- package/src/features/company/data/entity-ai-status.ts +13 -0
- package/src/features/company/data/index.ts +1 -0
- package/src/features/user/components/containers/PlatformUsersContainer.tsx +23 -0
- package/src/features/user/components/containers/index.ts +1 -0
- package/src/features/user/components/lists/PlatformUsersList.tsx +45 -0
- package/src/features/user/components/lists/index.ts +1 -0
- package/src/features/user/data/user.fields.ts +1 -0
- package/src/features/user/hooks/useUserTableStructure.tsx +17 -0
- package/dist/BlockNoteEditor-ADT6II2V.mjs.map +0 -1
- package/dist/BlockNoteEditor-QTIDATPF.js.map +0 -1
- package/dist/chunk-DLTMYGLR.mjs.map +0 -1
- package/dist/chunk-F3BPNQDB.js.map +0 -1
- package/dist/chunk-KSU6CCW6.js.map +0 -1
- package/dist/chunk-YEPE2HT6.mjs.map +0 -1
- /package/dist/{chunk-7DJOQTNN.mjs.map → chunk-3LVSA7IM.mjs.map} +0 -0
package/dist/server/index.mjs
CHANGED
package/package.json
CHANGED
package/src/billing/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Billing components - separate entry point to avoid loading Stripe.js globally
|
|
2
2
|
export * from "../features/billing/components";
|
|
3
|
+
export * from "../features/billing/contexts";
|
|
3
4
|
export * from "../features/billing/stripe-customer/components";
|
|
4
5
|
export * from "../features/billing/stripe-invoice/components";
|
|
5
6
|
export * from "../features/billing/stripe-price/components";
|
|
@@ -93,6 +93,11 @@ export type BlockNoteEditorProps = {
|
|
|
93
93
|
// height and scrolls internally. Without it the editor grows to fit its
|
|
94
94
|
// content and pushes the surrounding form to scroll instead.
|
|
95
95
|
stretch?: boolean;
|
|
96
|
+
// Gates AI on top of `aiConfig` (credit availability, etc.). Defaults to
|
|
97
|
+
// true so existing callers that only pass `aiConfig` are unaffected. When
|
|
98
|
+
// false, no AI extension is created, the AI toolbar button and AI slash
|
|
99
|
+
// items are hidden, and the editor falls back to the plain slash menu.
|
|
100
|
+
aiEnabled?: boolean;
|
|
96
101
|
};
|
|
97
102
|
|
|
98
103
|
function isBlockEmpty(block: any): boolean {
|
|
@@ -408,6 +413,7 @@ export default function BlockNoteEditor({
|
|
|
408
413
|
onWarmMentions,
|
|
409
414
|
aiConfig,
|
|
410
415
|
stretch,
|
|
416
|
+
aiEnabled = true,
|
|
411
417
|
}: BlockNoteEditorProps): React.JSX.Element {
|
|
412
418
|
const t = useTranslations();
|
|
413
419
|
const locale = useI18nLocale();
|
|
@@ -483,7 +489,7 @@ export default function BlockNoteEditor({
|
|
|
483
489
|
|
|
484
490
|
const companyId = company?.id;
|
|
485
491
|
const aiExtension = useMemo(() => {
|
|
486
|
-
if (!aiConfig) return undefined;
|
|
492
|
+
if (!aiConfig || !aiEnabled) return undefined;
|
|
487
493
|
const base = getPublicApiUrl();
|
|
488
494
|
const url = new URL(aiConfig.endpoint, base.endsWith("/") ? base : base + "/").toString();
|
|
489
495
|
return AIExtension({
|
|
@@ -524,7 +530,7 @@ export default function BlockNoteEditor({
|
|
|
524
530
|
},
|
|
525
531
|
}),
|
|
526
532
|
});
|
|
527
|
-
}, [aiConfig, companyId, locale]);
|
|
533
|
+
}, [aiConfig, aiEnabled, companyId, locale]);
|
|
528
534
|
|
|
529
535
|
const uploadImage = useCallback(
|
|
530
536
|
async (file: File): Promise<string> => {
|
|
@@ -834,7 +840,7 @@ export default function BlockNoteEditor({
|
|
|
834
840
|
onChange={handleChange}
|
|
835
841
|
editable={onChange !== undefined}
|
|
836
842
|
formattingToolbar={false}
|
|
837
|
-
slashMenu={!aiConfig}
|
|
843
|
+
slashMenu={!aiConfig || !aiEnabled}
|
|
838
844
|
// Follow the app theme: hardcoding "light" leaves dark (light-theme)
|
|
839
845
|
// text on the dark card, which is nearly unreadable in dark mode.
|
|
840
846
|
theme={resolvedTheme === "dark" ? "dark" : "light"}
|
|
@@ -854,7 +860,7 @@ export default function BlockNoteEditor({
|
|
|
854
860
|
size === "sm" && "small",
|
|
855
861
|
)}
|
|
856
862
|
>
|
|
857
|
-
<BlockNoteEditorFormattingToolbar showAI={!!aiConfig} />
|
|
863
|
+
<BlockNoteEditorFormattingToolbar showAI={!!aiConfig && aiEnabled} />
|
|
858
864
|
{enableMentions && mentionSearchFn && (
|
|
859
865
|
<BlockNoteEditorMentionSuggestionMenu
|
|
860
866
|
editor={editor}
|
|
@@ -867,7 +873,7 @@ export default function BlockNoteEditor({
|
|
|
867
873
|
{enableMentions && mentionResolveFn && (
|
|
868
874
|
<BlockNoteEditorMentionHoverCard containerRef={editorRef} mentionResolveFn={mentionResolveFn} />
|
|
869
875
|
)}
|
|
870
|
-
{aiConfig && (
|
|
876
|
+
{aiConfig && aiEnabled && (
|
|
871
877
|
<SuggestionMenuController
|
|
872
878
|
triggerCharacter="/"
|
|
873
879
|
getItems={async (query: string) =>
|
|
@@ -875,7 +881,7 @@ export default function BlockNoteEditor({
|
|
|
875
881
|
}
|
|
876
882
|
/>
|
|
877
883
|
)}
|
|
878
|
-
{aiConfig && <AIMenuController aiMenu={() => <NarrAIMenu />} />}
|
|
884
|
+
{aiConfig && aiEnabled && <AIMenuController aiMenu={() => <NarrAIMenu />} />}
|
|
879
885
|
</BlockNoteView>
|
|
880
886
|
</div>
|
|
881
887
|
);
|
package/src/components/index.ts
CHANGED
|
@@ -20,6 +20,7 @@ export { parseFiscalData } from "../utils/fiscal-utils";
|
|
|
20
20
|
|
|
21
21
|
// Feature components
|
|
22
22
|
export * from "../features/auth/components";
|
|
23
|
+
export * from "../features/administration";
|
|
23
24
|
// Billing components moved to separate entry point: @carlonicora/nextjs-jsonapi/billing
|
|
24
25
|
export * from "../features/company/components";
|
|
25
26
|
export * from "../features/content/components";
|
package/src/contexts/index.ts
CHANGED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Building2Icon, CoinsIcon, CreditCardIcon, ShieldCheckIcon, UsersIcon } from "lucide-react";
|
|
4
|
+
import { useTranslations } from "next-intl";
|
|
5
|
+
import { getStripePublishableKey } from "../../../client/config";
|
|
6
|
+
import { MicroLabel, RoundPageContainer } from "../../../components";
|
|
7
|
+
import { Link } from "../../../shadcnui";
|
|
8
|
+
import { AdministrationProvider } from "../contexts/AdministrationContext";
|
|
9
|
+
import type { AdminGroup, AdminSection } from "../data/admin-section.types";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The sections every NJA project has. Hrefs are constants rather than props:
|
|
13
|
+
* the path convention IS the contract that makes this component reusable, and
|
|
14
|
+
* it already exists in the package (see TOKEN_USAGE_ADMIN_PAGE_URL in
|
|
15
|
+
* features/tokenusage/contexts/TokenUsageAdminContext.tsx).
|
|
16
|
+
*/
|
|
17
|
+
const PLATFORM_SECTIONS: AdminSection[] = [
|
|
18
|
+
{
|
|
19
|
+
key: "companies",
|
|
20
|
+
icon: Building2Icon,
|
|
21
|
+
labelKey: "entities.companies",
|
|
22
|
+
labelValues: { count: 2 },
|
|
23
|
+
descriptionKey: "administration.companies.description",
|
|
24
|
+
href: "/administration/companies",
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
key: "users",
|
|
28
|
+
icon: UsersIcon,
|
|
29
|
+
labelKey: "entities.users",
|
|
30
|
+
labelValues: { count: 2 },
|
|
31
|
+
descriptionKey: "administration.users.description",
|
|
32
|
+
href: "/administration/users",
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
key: "token-usage",
|
|
36
|
+
icon: CoinsIcon,
|
|
37
|
+
labelKey: "token_usage.admin.title",
|
|
38
|
+
descriptionKey: "administration.token_usage.description",
|
|
39
|
+
href: "/administration/token-usage",
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
key: "rbac",
|
|
43
|
+
icon: ShieldCheckIcon,
|
|
44
|
+
labelKey: "entities.rbac",
|
|
45
|
+
labelValues: { count: 2 },
|
|
46
|
+
descriptionKey: "administration.rbac.description",
|
|
47
|
+
href: "/administration/rbac",
|
|
48
|
+
},
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The only conditional entry. getStripePublishableKey comes from /client rather
|
|
53
|
+
* than isStripeConfigured() from /billing on purpose: /billing exists to keep
|
|
54
|
+
* Stripe.js out of the global bundle, and isStripeConfigured lives in
|
|
55
|
+
* StripeProvider.tsx, which imports @stripe/stripe-js at module scope. The two
|
|
56
|
+
* are the same check — isStripeConfigured is a one-line wrapper over this call.
|
|
57
|
+
*/
|
|
58
|
+
const BILLING_SECTION: AdminSection = {
|
|
59
|
+
key: "billing",
|
|
60
|
+
icon: CreditCardIcon,
|
|
61
|
+
labelKey: "billing.admin.products.title",
|
|
62
|
+
descriptionKey: "administration.billing.description",
|
|
63
|
+
href: "/administration/billing",
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** Keys of the built-in platform sections, for `hiddenSections`. */
|
|
67
|
+
export type PlatformSectionKey = "companies" | "users" | "token-usage" | "rbac" | "billing";
|
|
68
|
+
|
|
69
|
+
type AdminIndexProps = {
|
|
70
|
+
/** Project-specific groups, appended after the built-in platform group. */
|
|
71
|
+
additionalGroups?: AdminGroup[];
|
|
72
|
+
/**
|
|
73
|
+
* Built-in sections to leave out. Every NJA project gets the same platform
|
|
74
|
+
* group by default, but a project whose backend does not wire a foundation
|
|
75
|
+
* must be able to drop its entry rather than advertise a route that throws —
|
|
76
|
+
* narr8, for instance, never calls `RbacModule.register()`, so it has no RBAC
|
|
77
|
+
* controllers at all.
|
|
78
|
+
*/
|
|
79
|
+
hiddenSections?: PlatformSectionKey[];
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The grid itself, with no page shell — this is what the specs render, because
|
|
84
|
+
* RoundPageContainer pulls in the app Header and its four header contexts.
|
|
85
|
+
*/
|
|
86
|
+
export function AdminIndexGrid({ additionalGroups = [], hiddenSections = [] }: AdminIndexProps) {
|
|
87
|
+
const t = useTranslations();
|
|
88
|
+
|
|
89
|
+
const available = getStripePublishableKey() ? [...PLATFORM_SECTIONS, BILLING_SECTION] : PLATFORM_SECTIONS;
|
|
90
|
+
|
|
91
|
+
const platformGroup: AdminGroup = {
|
|
92
|
+
key: "platform",
|
|
93
|
+
labelKey: "administration.group.platform",
|
|
94
|
+
sections: available.filter((section) => !hiddenSections.includes(section.key as PlatformSectionKey)),
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const groups = [platformGroup, ...additionalGroups].filter((group) => group.sections.length > 0);
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<div className="mx-auto flex w-full max-w-5xl flex-1 flex-col gap-8 py-8">
|
|
101
|
+
{/* No page title here: AdministrationProvider feeds "Administration" to
|
|
102
|
+
RoundPageContainerTitle, which owns the page's name. Repeating it as a
|
|
103
|
+
role-1 title would triple up with that strip and the breadcrumb. */}
|
|
104
|
+
<p className="text-muted-foreground text-sm">{t("administration.subtitle")}</p>
|
|
105
|
+
|
|
106
|
+
{groups.map((group) => (
|
|
107
|
+
<div key={group.key}>
|
|
108
|
+
<MicroLabel as="h3" className="mb-2 px-3">
|
|
109
|
+
{t(group.labelKey, group.labelValues)}
|
|
110
|
+
</MicroLabel>
|
|
111
|
+
<div className="grid grid-cols-1 gap-1 md:grid-cols-2 lg:grid-cols-3">
|
|
112
|
+
{group.sections.map((section) => (
|
|
113
|
+
<Link
|
|
114
|
+
key={section.key}
|
|
115
|
+
href={section.href}
|
|
116
|
+
className="text-foreground font-normal"
|
|
117
|
+
data-testid={`admin-index-${section.key}`}
|
|
118
|
+
>
|
|
119
|
+
<div className="hover:bg-muted/50 flex cursor-pointer items-center gap-3 rounded-md px-3 py-2">
|
|
120
|
+
<section.icon className="text-muted-foreground h-4 w-4 shrink-0" />
|
|
121
|
+
<div className="flex flex-col">
|
|
122
|
+
<span className="text-sm font-medium">{t(section.labelKey, section.labelValues)}</span>
|
|
123
|
+
<span className="text-muted-foreground text-xs">
|
|
124
|
+
{t(section.descriptionKey, section.descriptionValues)}
|
|
125
|
+
</span>
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
</Link>
|
|
129
|
+
))}
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
))}
|
|
133
|
+
</div>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Drop-in administration landing page. Mount it bare — it brings its own shell
|
|
139
|
+
* and its own page chrome (AdministrationProvider), so it does not inherit the
|
|
140
|
+
* title and breadcrumb of whatever page rendered before it.
|
|
141
|
+
*/
|
|
142
|
+
export function AdminIndexContainer({ additionalGroups, hiddenSections }: AdminIndexProps) {
|
|
143
|
+
return (
|
|
144
|
+
<AdministrationProvider>
|
|
145
|
+
<RoundPageContainer>
|
|
146
|
+
<AdminIndexGrid additionalGroups={additionalGroups} hiddenSections={hiddenSections} />
|
|
147
|
+
</RoundPageContainer>
|
|
148
|
+
</AdministrationProvider>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { BookOpenIcon } from "lucide-react";
|
|
3
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
4
|
+
import { configureJsonApi } from "../../../../client/config";
|
|
5
|
+
import { configureI18n } from "../../../../i18n";
|
|
6
|
+
import { AdminIndexGrid } from "../AdminIndexContainer";
|
|
7
|
+
import type { AdminGroup } from "../../data/admin-section.types";
|
|
8
|
+
|
|
9
|
+
// next-intl is globally mocked in vitest.setup.ts with `t = (key) => key`, so
|
|
10
|
+
// every label below renders as its own key. That is what the assertions match.
|
|
11
|
+
const configure = (stripePublishableKey?: string) => {
|
|
12
|
+
configureJsonApi({ apiUrl: "http://api.test", stripePublishableKey });
|
|
13
|
+
configureI18n({
|
|
14
|
+
useRouter: () => ({
|
|
15
|
+
push: () => {},
|
|
16
|
+
replace: () => {},
|
|
17
|
+
back: () => {},
|
|
18
|
+
forward: () => {},
|
|
19
|
+
refresh: () => {},
|
|
20
|
+
prefetch: () => {},
|
|
21
|
+
}),
|
|
22
|
+
useTranslations: () => (key: string) => key,
|
|
23
|
+
usePathname: () => "/administration",
|
|
24
|
+
Link: ({ href, children, ...props }: any) => (
|
|
25
|
+
<a href={href} {...props}>
|
|
26
|
+
{children}
|
|
27
|
+
</a>
|
|
28
|
+
),
|
|
29
|
+
});
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const extraGroup: AdminGroup = {
|
|
33
|
+
key: "documentation",
|
|
34
|
+
labelKey: "administration.group.documentation",
|
|
35
|
+
sections: [
|
|
36
|
+
{
|
|
37
|
+
key: "howtos",
|
|
38
|
+
icon: BookOpenIcon,
|
|
39
|
+
labelKey: "entities.howtos",
|
|
40
|
+
labelValues: { count: 2 },
|
|
41
|
+
descriptionKey: "administration.howtos.description",
|
|
42
|
+
href: "/administration/howtos",
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
describe("AdminIndexGrid", () => {
|
|
48
|
+
beforeEach(() => configure());
|
|
49
|
+
|
|
50
|
+
it("renders the four unconditional platform entries with their hrefs", () => {
|
|
51
|
+
render(<AdminIndexGrid />);
|
|
52
|
+
|
|
53
|
+
expect(screen.getByTestId("admin-index-companies")).toHaveAttribute("href", "/administration/companies");
|
|
54
|
+
expect(screen.getByTestId("admin-index-users")).toHaveAttribute("href", "/administration/users");
|
|
55
|
+
expect(screen.getByTestId("admin-index-token-usage")).toHaveAttribute("href", "/administration/token-usage");
|
|
56
|
+
expect(screen.getByTestId("admin-index-rbac")).toHaveAttribute("href", "/administration/rbac");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("omits the billing entry when no Stripe publishable key is configured", () => {
|
|
60
|
+
render(<AdminIndexGrid />);
|
|
61
|
+
|
|
62
|
+
expect(screen.queryByTestId("admin-index-billing")).not.toBeInTheDocument();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("includes the billing entry when a Stripe publishable key is configured", () => {
|
|
66
|
+
configure("pk_test_123");
|
|
67
|
+
render(<AdminIndexGrid />);
|
|
68
|
+
|
|
69
|
+
expect(screen.getByTestId("admin-index-billing")).toHaveAttribute("href", "/administration/billing");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("appends additionalGroups after the platform group, in order", () => {
|
|
73
|
+
render(<AdminIndexGrid additionalGroups={[extraGroup]} />);
|
|
74
|
+
|
|
75
|
+
const headings = screen.getAllByRole("heading", { level: 3 }).map((h) => h.textContent);
|
|
76
|
+
expect(headings).toEqual(["administration.group.platform", "administration.group.documentation"]);
|
|
77
|
+
expect(screen.getByTestId("admin-index-howtos")).toHaveAttribute("href", "/administration/howtos");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("skips a group that has no sections", () => {
|
|
81
|
+
render(
|
|
82
|
+
<AdminIndexGrid additionalGroups={[{ key: "empty", labelKey: "administration.group.empty", sections: [] }]} />,
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
expect(screen.queryByText("administration.group.empty")).not.toBeInTheDocument();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("renders the subtitle but not a page title of its own", () => {
|
|
89
|
+
render(<AdminIndexGrid />);
|
|
90
|
+
|
|
91
|
+
expect(screen.getByText("administration.subtitle")).toBeInTheDocument();
|
|
92
|
+
// The page's name comes from AdministrationProvider via
|
|
93
|
+
// RoundPageContainerTitle, so the grid must not repeat it.
|
|
94
|
+
expect(screen.queryByText("administration.title")).not.toBeInTheDocument();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { AdminIndexContainer, AdminIndexGrid } from "./AdminIndexContainer";
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useTranslations } from "next-intl";
|
|
4
|
+
import { createContext, ReactNode, useContext } from "react";
|
|
5
|
+
import { SharedProvider } from "../../../contexts/SharedContext";
|
|
6
|
+
import { BreadcrumbItemData } from "../../../interfaces";
|
|
7
|
+
|
|
8
|
+
interface AdministrationContextType {}
|
|
9
|
+
|
|
10
|
+
const AdministrationContext = createContext<AdministrationContextType | undefined>(undefined);
|
|
11
|
+
|
|
12
|
+
type AdministrationProviderProps = {
|
|
13
|
+
children: ReactNode;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Supplies the page chrome for every administration surface.
|
|
18
|
+
*
|
|
19
|
+
* `RoundPageContainerTitle` and the header breadcrumb both read
|
|
20
|
+
* `useSharedContext()`, and that context is provided far up the tree by the
|
|
21
|
+
* layout — so an administration page that provides nothing of its own silently
|
|
22
|
+
* inherits whatever the *previously rendered* page put there (a company name, a
|
|
23
|
+
* "Notifications" title). Wrapping in this provider replaces that value, which
|
|
24
|
+
* is why every administration container mounts inside it.
|
|
25
|
+
*
|
|
26
|
+
* Mirrors CommonProvider (contexts/CommonContext.tsx), which does the same job
|
|
27
|
+
* for the ordinary application shell.
|
|
28
|
+
*/
|
|
29
|
+
export const AdministrationProvider = ({ children }: AdministrationProviderProps) => {
|
|
30
|
+
const t = useTranslations();
|
|
31
|
+
|
|
32
|
+
const breadcrumbs: BreadcrumbItemData[] = [];
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<SharedProvider value={{ breadcrumbs, title: { type: t(`administration.title`) } }}>
|
|
36
|
+
<AdministrationContext.Provider value={{}}>{children}</AdministrationContext.Provider>
|
|
37
|
+
</SharedProvider>
|
|
38
|
+
);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const useAdministrationContext = (): AdministrationContextType => {
|
|
42
|
+
const context = useContext(AdministrationContext);
|
|
43
|
+
if (context === undefined) {
|
|
44
|
+
throw new Error("useAdministrationContext must be used within an AdministrationProvider");
|
|
45
|
+
}
|
|
46
|
+
return context;
|
|
47
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./AdministrationContext";
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { LucideIcon } from "lucide-react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One row of the administration index: an icon, a translated label and
|
|
5
|
+
* description, and the route it opens.
|
|
6
|
+
*
|
|
7
|
+
* Labels and descriptions are i18n *keys*, never resolved strings, so
|
|
8
|
+
* AdminIndexGrid stays the single place that calls useTranslations().
|
|
9
|
+
* `labelValues` / `descriptionValues` carry ICU arguments — `{ count: 2 }` for
|
|
10
|
+
* the `entities.*` plural keys, or `{ type: "..." }` for an app reusing its own
|
|
11
|
+
* parameterised heading key.
|
|
12
|
+
*/
|
|
13
|
+
export type AdminSection = {
|
|
14
|
+
/** Stable React key, and the `admin-index-<key>` test id on the rendered link. */
|
|
15
|
+
key: string;
|
|
16
|
+
icon: LucideIcon;
|
|
17
|
+
labelKey: string;
|
|
18
|
+
labelValues?: Record<string, string | number>;
|
|
19
|
+
descriptionKey: string;
|
|
20
|
+
descriptionValues?: Record<string, string | number>;
|
|
21
|
+
href: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** A titled block of sections. The built-in "platform" group always renders first. */
|
|
25
|
+
export type AdminGroup = {
|
|
26
|
+
key: string;
|
|
27
|
+
labelKey: string;
|
|
28
|
+
labelValues?: Record<string, string | number>;
|
|
29
|
+
sections: AdminSection[];
|
|
30
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fixed namespace of i18n keys the administration index reads via
|
|
3
|
+
* useTranslations. Consuming apps must define each entry in their
|
|
4
|
+
* messages/<locale>.json — this list is the contract between the package and
|
|
5
|
+
* the app, and the names are frozen: an app that wants different wording
|
|
6
|
+
* changes its own translation, never the key.
|
|
7
|
+
*
|
|
8
|
+
* Section LABELS are deliberately absent: they reuse keys every NJA app already
|
|
9
|
+
* defines — `entities.companies`, `entities.users`, `entities.rbac` (all with
|
|
10
|
+
* `{ count: 2 }`), `token_usage.admin.title`, and
|
|
11
|
+
* `billing.admin.products.title` — so section names stay in each app's own
|
|
12
|
+
* vocabulary.
|
|
13
|
+
*
|
|
14
|
+
* Mirrors the contract shipped by the token-usage feature
|
|
15
|
+
* (features/tokenusage/i18n-keys.ts).
|
|
16
|
+
*/
|
|
17
|
+
export const ADMINISTRATION_I18N_KEYS = [
|
|
18
|
+
"administration.title",
|
|
19
|
+
"administration.subtitle",
|
|
20
|
+
"administration.group.platform",
|
|
21
|
+
"administration.companies.description",
|
|
22
|
+
"administration.users.description",
|
|
23
|
+
"administration.token_usage.description",
|
|
24
|
+
"administration.rbac.description",
|
|
25
|
+
"administration.billing.description",
|
|
26
|
+
] as const;
|
|
@@ -31,6 +31,12 @@ interface Props {
|
|
|
31
31
|
/** Rendered inside the sidebar's details panel above the thread list. */
|
|
32
32
|
detailsTitle: string;
|
|
33
33
|
detailsIcon: React.ReactNode;
|
|
34
|
+
/**
|
|
35
|
+
* Disables the composer for a reason external to this component (e.g. the
|
|
36
|
+
* consuming app's credit gate) — ORed with the in-flight `ctx.sending`
|
|
37
|
+
* disable, never replacing it.
|
|
38
|
+
*/
|
|
39
|
+
disabled?: boolean;
|
|
34
40
|
}
|
|
35
41
|
|
|
36
42
|
/**
|
|
@@ -47,6 +53,7 @@ export function AssistantPageContainer({
|
|
|
47
53
|
suggestionMenuComponent,
|
|
48
54
|
detailsTitle,
|
|
49
55
|
detailsIcon,
|
|
56
|
+
disabled,
|
|
50
57
|
}: Props) {
|
|
51
58
|
const t = useTranslations();
|
|
52
59
|
const ctx = useAssistantContext();
|
|
@@ -111,7 +118,7 @@ export function AssistantPageContainer({
|
|
|
111
118
|
)}
|
|
112
119
|
<AssistantBlockNoteComposer
|
|
113
120
|
onSend={(content, opts) => ctx.sendMessage(content, { contentBlocks: opts.contentBlocks })}
|
|
114
|
-
disabled={ctx.sending}
|
|
121
|
+
disabled={ctx.sending || disabled}
|
|
115
122
|
mentionSearchFn={mentionSearchFn}
|
|
116
123
|
mentionSearchParams={mentionSearchParams}
|
|
117
124
|
mentionResolveFn={mentionResolveFn}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useTranslations } from "next-intl";
|
|
4
|
+
import { createContext, ReactNode, useCallback, useContext, useState } from "react";
|
|
5
|
+
import { SharedProvider } from "../../../contexts/SharedContext";
|
|
6
|
+
import { BreadcrumbItemData } from "../../../interfaces";
|
|
7
|
+
import { getRoleId } from "../../../roles";
|
|
8
|
+
import { Button } from "../../../shadcnui";
|
|
9
|
+
import { useCurrentUserContext } from "../../user/contexts";
|
|
10
|
+
import { ProductEditor } from "../stripe-product/components/forms/ProductEditor";
|
|
11
|
+
|
|
12
|
+
interface BillingContextType {
|
|
13
|
+
/**
|
|
14
|
+
* Bumped every time the provider's own actions change the product catalogue.
|
|
15
|
+
* ProductsAdminContainer reloads on it — the create action lives up here in
|
|
16
|
+
* the page title bar, so it cannot call the container's loader directly.
|
|
17
|
+
*/
|
|
18
|
+
refreshToken: number;
|
|
19
|
+
refresh: () => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const BillingContext = createContext<BillingContextType | undefined>(undefined);
|
|
23
|
+
|
|
24
|
+
type BillingProviderProps = {
|
|
25
|
+
children: ReactNode;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Page chrome for the administrative billing surface (Stripe products, prices
|
|
30
|
+
* and their credit allowances).
|
|
31
|
+
*
|
|
32
|
+
* Billing gets its own provider rather than borrowing AdministrationProvider so
|
|
33
|
+
* the page carries its own name — and, like CompanyProvider with CompanyEditor,
|
|
34
|
+
* it owns the page's ACTIONS: the create-product control is published through
|
|
35
|
+
* `title.functions`, which RoundPageContainerTitle renders in the header bar.
|
|
36
|
+
* That is where every other page in this codebase puts its primary action.
|
|
37
|
+
*/
|
|
38
|
+
export const BillingProvider = ({ children }: BillingProviderProps) => {
|
|
39
|
+
const t = useTranslations();
|
|
40
|
+
const { hasRole } = useCurrentUserContext();
|
|
41
|
+
const [showCreateProduct, setShowCreateProduct] = useState<boolean>(false);
|
|
42
|
+
const [refreshToken, setRefreshToken] = useState<number>(0);
|
|
43
|
+
|
|
44
|
+
const refresh = useCallback(() => setRefreshToken((value) => value + 1), []);
|
|
45
|
+
|
|
46
|
+
// No href: this crumb IS the current page. Linking it would also point at the
|
|
47
|
+
// StripeProduct module's `pageUrl` (/stripe-products), which no consuming app
|
|
48
|
+
// routes — the page lives under the administration tree.
|
|
49
|
+
const breadcrumbs: BreadcrumbItemData[] = [{ name: t(`billing.admin.products.title`) }];
|
|
50
|
+
|
|
51
|
+
const title = () => {
|
|
52
|
+
const response: any = { type: t(`billing.admin.products.title`) };
|
|
53
|
+
|
|
54
|
+
if (hasRole(getRoleId().Administrator)) {
|
|
55
|
+
response.functions = [
|
|
56
|
+
<Button key="productEditorTrigger" onClick={() => setShowCreateProduct(true)}>
|
|
57
|
+
{t(`billing.admin.products.create`)}
|
|
58
|
+
</Button>,
|
|
59
|
+
showCreateProduct ? (
|
|
60
|
+
<ProductEditor
|
|
61
|
+
key="productEditor"
|
|
62
|
+
open={showCreateProduct}
|
|
63
|
+
onOpenChange={setShowCreateProduct}
|
|
64
|
+
onSuccess={() => {
|
|
65
|
+
setShowCreateProduct(false);
|
|
66
|
+
refresh();
|
|
67
|
+
}}
|
|
68
|
+
/>
|
|
69
|
+
) : null,
|
|
70
|
+
];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return response;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<SharedProvider value={{ breadcrumbs, title: title() }}>
|
|
78
|
+
<BillingContext.Provider value={{ refreshToken, refresh }}>{children}</BillingContext.Provider>
|
|
79
|
+
</SharedProvider>
|
|
80
|
+
);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export const useBillingContext = (): BillingContextType => {
|
|
84
|
+
const context = useContext(BillingContext);
|
|
85
|
+
if (context === undefined) {
|
|
86
|
+
throw new Error("useBillingContext must be used within a BillingProvider");
|
|
87
|
+
}
|
|
88
|
+
return context;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Non-throwing variant. ProductsAdminContainer predates this provider and is
|
|
93
|
+
* still mounted bare by other consumers, so it must keep working without one.
|
|
94
|
+
*/
|
|
95
|
+
export const useOptionalBillingContext = (): BillingContextType | undefined => useContext(BillingContext);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./BillingContext";
|