@stigmer/react 3.39.1-dev.20260928194002 → 3.40.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.
- package/billing/AutoRechargeCard.js +1 -1
- package/billing/AutoRechargeCard.js.map +1 -1
- package/billing/BillingSection.d.ts +18 -1
- package/billing/BillingSection.d.ts.map +1 -1
- package/billing/BillingSection.js +21 -11
- package/billing/BillingSection.js.map +1 -1
- package/billing/ChangePlanDialog.d.ts +46 -0
- package/billing/ChangePlanDialog.d.ts.map +1 -0
- package/billing/ChangePlanDialog.js +90 -0
- package/billing/ChangePlanDialog.js.map +1 -0
- package/billing/CreditPackGrid.d.ts +1 -1
- package/billing/CreditPackGrid.js +1 -1
- package/billing/LowBalanceBanner.js +1 -1
- package/billing/LowBalanceBanner.js.map +1 -1
- package/billing/PaymentMethodCard.d.ts +9 -1
- package/billing/PaymentMethodCard.d.ts.map +1 -1
- package/billing/PaymentMethodCard.js +2 -2
- package/billing/PaymentMethodCard.js.map +1 -1
- package/billing/PlanCard.d.ts +27 -0
- package/billing/PlanCard.d.ts.map +1 -0
- package/billing/PlanCard.js +68 -0
- package/billing/PlanCard.js.map +1 -0
- package/billing/PlanPicker.d.ts +34 -0
- package/billing/PlanPicker.d.ts.map +1 -0
- package/billing/PlanPicker.js +46 -0
- package/billing/PlanPicker.js.map +1 -0
- package/billing/PlanSection.d.ts +45 -0
- package/billing/PlanSection.d.ts.map +1 -0
- package/billing/PlanSection.js +136 -0
- package/billing/PlanSection.js.map +1 -0
- package/billing/UpgradeNotice.d.ts +47 -0
- package/billing/UpgradeNotice.d.ts.map +1 -0
- package/billing/UpgradeNotice.js +49 -0
- package/billing/UpgradeNotice.js.map +1 -0
- package/billing/credit-packs.d.ts.map +1 -1
- package/billing/credit-packs.js +4 -1
- package/billing/credit-packs.js.map +1 -1
- package/billing/index.d.ts +26 -1
- package/billing/index.d.ts.map +1 -1
- package/billing/index.js +12 -0
- package/billing/index.js.map +1 -1
- package/billing/plan-features.d.ts +60 -0
- package/billing/plan-features.d.ts.map +1 -0
- package/billing/plan-features.js +128 -0
- package/billing/plan-features.js.map +1 -0
- package/billing/plan-state.d.ts +43 -0
- package/billing/plan-state.d.ts.map +1 -0
- package/billing/plan-state.js +69 -0
- package/billing/plan-state.js.map +1 -0
- package/billing/redirect.d.ts +31 -0
- package/billing/redirect.d.ts.map +1 -0
- package/billing/redirect.js +30 -0
- package/billing/redirect.js.map +1 -0
- package/billing/useBillingAccount.d.ts +10 -1
- package/billing/useBillingAccount.d.ts.map +1 -1
- package/billing/useBillingAccount.js +2 -2
- package/billing/useBillingAccount.js.map +1 -1
- package/billing/useChangePlan.d.ts +51 -0
- package/billing/useChangePlan.d.ts.map +1 -0
- package/billing/useChangePlan.js +61 -0
- package/billing/useChangePlan.js.map +1 -0
- package/billing/useCreateBillingPortalSession.d.ts +6 -3
- package/billing/useCreateBillingPortalSession.d.ts.map +1 -1
- package/billing/useCreateBillingPortalSession.js +11 -4
- package/billing/useCreateBillingPortalSession.js.map +1 -1
- package/billing/useCreateCheckoutSession.d.ts +7 -5
- package/billing/useCreateCheckoutSession.d.ts.map +1 -1
- package/billing/useCreateCheckoutSession.js +5 -3
- package/billing/useCreateCheckoutSession.js.map +1 -1
- package/billing/useCreatePaymentMethodSetupSession.d.ts +28 -0
- package/billing/useCreatePaymentMethodSetupSession.d.ts.map +1 -0
- package/billing/useCreatePaymentMethodSetupSession.js +50 -0
- package/billing/useCreatePaymentMethodSetupSession.js.map +1 -0
- package/billing/useEntitlements.d.ts +46 -0
- package/billing/useEntitlements.d.ts.map +1 -0
- package/billing/useEntitlements.js +37 -0
- package/billing/useEntitlements.js.map +1 -0
- package/billing/usePeriodEstimate.d.ts +35 -0
- package/billing/usePeriodEstimate.d.ts.map +1 -0
- package/billing/usePeriodEstimate.js +29 -0
- package/billing/usePeriodEstimate.js.map +1 -0
- package/billing/usePlans.d.ts +38 -0
- package/billing/usePlans.d.ts.map +1 -0
- package/billing/usePlans.js +33 -0
- package/billing/usePlans.js.map +1 -0
- package/billing/useSubscription.d.ts +39 -0
- package/billing/useSubscription.d.ts.map +1 -0
- package/billing/useSubscription.js +40 -0
- package/billing/useSubscription.js.map +1 -0
- package/channel-app/ChannelAppDetailPanel.d.ts +5 -0
- package/channel-app/ChannelAppDetailPanel.d.ts.map +1 -1
- package/channel-app/ChannelAppDetailPanel.js +9 -3
- package/channel-app/ChannelAppDetailPanel.js.map +1 -1
- package/channel-app/ChannelAppListPanel.d.ts +13 -4
- package/channel-app/ChannelAppListPanel.d.ts.map +1 -1
- package/channel-app/ChannelAppListPanel.js +5 -5
- package/channel-app/ChannelAppListPanel.js.map +1 -1
- package/channel-app/copy.d.ts +15 -0
- package/channel-app/copy.d.ts.map +1 -0
- package/channel-app/copy.js +15 -0
- package/channel-app/copy.js.map +1 -0
- package/index.d.ts +4 -2
- package/index.d.ts.map +1 -1
- package/index.js +4 -1
- package/index.js.map +1 -1
- package/internal/features.d.ts +11 -0
- package/internal/features.d.ts.map +1 -0
- package/internal/features.js +42 -0
- package/internal/features.js.map +1 -0
- package/licenses/license-format.d.ts +5 -3
- package/licenses/license-format.d.ts.map +1 -1
- package/licenses/license-format.js +10 -19
- package/licenses/license-format.js.map +1 -1
- package/package.json +5 -5
- package/plan-catalog/PlanCatalogConsole.d.ts +22 -0
- package/plan-catalog/PlanCatalogConsole.d.ts.map +1 -0
- package/plan-catalog/PlanCatalogConsole.js +90 -0
- package/plan-catalog/PlanCatalogConsole.js.map +1 -0
- package/plan-catalog/PlanCreateForm.d.ts +17 -0
- package/plan-catalog/PlanCreateForm.d.ts.map +1 -0
- package/plan-catalog/PlanCreateForm.js +52 -0
- package/plan-catalog/PlanCreateForm.js.map +1 -0
- package/plan-catalog/PlansAccessNotice.d.ts +10 -0
- package/plan-catalog/PlansAccessNotice.d.ts.map +1 -0
- package/plan-catalog/PlansAccessNotice.js +13 -0
- package/plan-catalog/PlansAccessNotice.js.map +1 -0
- package/plan-catalog/index.d.ts +8 -0
- package/plan-catalog/index.d.ts.map +1 -0
- package/plan-catalog/index.js +10 -0
- package/plan-catalog/index.js.map +1 -0
- package/plan-catalog/plan-draft.d.ts +30 -0
- package/plan-catalog/plan-draft.d.ts.map +1 -0
- package/plan-catalog/plan-draft.js +91 -0
- package/plan-catalog/plan-draft.js.map +1 -0
- package/plan-catalog/usePlanMutations.d.ts +42 -0
- package/plan-catalog/usePlanMutations.d.ts.map +1 -0
- package/plan-catalog/usePlanMutations.js +57 -0
- package/plan-catalog/usePlanMutations.js.map +1 -0
- package/settings/ChannelAppsSection.d.ts +7 -0
- package/settings/ChannelAppsSection.d.ts.map +1 -1
- package/settings/ChannelAppsSection.js +15 -2
- package/settings/ChannelAppsSection.js.map +1 -1
- package/settings/TeamsSection.d.ts.map +1 -1
- package/settings/TeamsSection.js +19 -2
- package/settings/TeamsSection.js.map +1 -1
- package/settings/settings-nav.d.ts.map +1 -1
- package/settings/settings-nav.js +7 -1
- package/settings/settings-nav.js.map +1 -1
- package/settings/useSettingsNavGroups.d.ts.map +1 -1
- package/settings/useSettingsNavGroups.js +4 -1
- package/settings/useSettingsNavGroups.js.map +1 -1
- package/src/billing/AutoRechargeCard.tsx +2 -2
- package/src/billing/BillingSection.tsx +68 -11
- package/src/billing/ChangePlanDialog.tsx +221 -0
- package/src/billing/CreditPackGrid.tsx +1 -1
- package/src/billing/LowBalanceBanner.tsx +1 -1
- package/src/billing/PaymentMethodCard.tsx +28 -2
- package/src/billing/PlanCard.tsx +171 -0
- package/src/billing/PlanPicker.tsx +178 -0
- package/src/billing/PlanSection.tsx +234 -0
- package/src/billing/UpgradeNotice.tsx +95 -0
- package/src/billing/credit-packs.ts +4 -1
- package/src/billing/index.ts +26 -1
- package/src/billing/plan-features.ts +162 -0
- package/src/billing/plan-state.ts +92 -0
- package/src/billing/redirect.ts +45 -0
- package/src/billing/useBillingAccount.ts +12 -0
- package/src/billing/useChangePlan.ts +115 -0
- package/src/billing/useCreateBillingPortalSession.ts +17 -6
- package/src/billing/useCreateCheckoutSession.ts +12 -7
- package/src/billing/useCreatePaymentMethodSetupSession.ts +77 -0
- package/src/billing/useEntitlements.ts +80 -0
- package/src/billing/usePeriodEstimate.ts +59 -0
- package/src/billing/usePlans.ts +65 -0
- package/src/billing/useSubscription.ts +70 -0
- package/src/channel-app/ChannelAppDetailPanel.tsx +179 -158
- package/src/channel-app/ChannelAppListPanel.tsx +14 -4
- package/src/channel-app/copy.ts +16 -0
- package/src/index.ts +54 -0
- package/src/internal/features.ts +49 -0
- package/src/licenses/license-format.ts +10 -19
- package/src/plan-catalog/PlanCatalogConsole.tsx +197 -0
- package/src/plan-catalog/PlanCreateForm.tsx +167 -0
- package/src/plan-catalog/PlansAccessNotice.tsx +20 -0
- package/src/plan-catalog/index.ts +14 -0
- package/src/plan-catalog/plan-draft.ts +116 -0
- package/src/plan-catalog/usePlanMutations.ts +94 -0
- package/src/settings/ChannelAppsSection.tsx +19 -2
- package/src/settings/TeamsSection.tsx +20 -2
- package/src/settings/settings-nav.ts +7 -0
- package/src/settings/useSettingsNavGroups.ts +8 -1
- package/src/team/CreateTeamForm.tsx +12 -3
- package/src/team/TeamMembersPanel.tsx +6 -2
- package/team/CreateTeamForm.d.ts +6 -1
- package/team/CreateTeamForm.d.ts.map +1 -1
- package/team/CreateTeamForm.js +4 -2
- package/team/CreateTeamForm.js.map +1 -1
- package/team/TeamMembersPanel.d.ts.map +1 -1
- package/team/TeamMembersPanel.js +3 -1
- package/team/TeamMembersPanel.js.map +1 -1
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { cn } from "@stigmer/theme";
|
|
5
|
+
import { getUserMessage, isUnimplemented } from "@stigmer/sdk";
|
|
6
|
+
import type { Plan } from "@stigmer/protos/ai/stigmer/billing/plan/v1/api_pb";
|
|
7
|
+
import { PlanInstrument } from "@stigmer/protos/ai/stigmer/billing/plan/v1/spec_pb";
|
|
8
|
+
import { PlanLifecycle } from "@stigmer/protos/ai/stigmer/billing/plan/v1/status_pb";
|
|
9
|
+
import { formatCreditBalance } from "../billing/format.js";
|
|
10
|
+
import { formatManagedOrganizations, formatUsageShare, offeredFeatures } from "../billing/plan-features.js";
|
|
11
|
+
import { usePlans } from "../billing/usePlans.js";
|
|
12
|
+
import { Button } from "../button/index.js";
|
|
13
|
+
import { ApiResourceKind, useResourceAvailable } from "../deployment-mode.js";
|
|
14
|
+
import { useCheckPermission } from "../iam-policy/useCheckPermission.js";
|
|
15
|
+
import { CloudFeatureNotice } from "../internal/CloudFeatureNotice.js";
|
|
16
|
+
import { UNSTYLED_LIST } from "../internal/element-resets.js";
|
|
17
|
+
import { FEATURE_COPY, isNamedFeature } from "../internal/features.js";
|
|
18
|
+
import { ConfirmDialog } from "../resource-detail/ConfirmDialog.js";
|
|
19
|
+
import { useConfirmAction } from "../resource-detail/useConfirmAction.js";
|
|
20
|
+
import { StatusBadge } from "../resource-workbench/components/StatusBadge.js";
|
|
21
|
+
import { PlanCreateForm } from "./PlanCreateForm.js";
|
|
22
|
+
import { PlansAccessNotice } from "./PlansAccessNotice.js";
|
|
23
|
+
import { useRetirePlan } from "./usePlanMutations.js";
|
|
24
|
+
|
|
25
|
+
/** The platform object the catalog's permission is held on. */
|
|
26
|
+
const PLATFORM = { kind: "platform", id: "stigmer" } as const;
|
|
27
|
+
|
|
28
|
+
/** Props for {@link PlanCatalogConsole}. */
|
|
29
|
+
export interface PlanCatalogConsoleProps {
|
|
30
|
+
/** Additional CSS class names. */
|
|
31
|
+
readonly className?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The platform-operator console for the Stigmer Cloud plan catalog: every
|
|
36
|
+
* plan, retired ones included, with its terms and what it offers; retiring
|
|
37
|
+
* a plan; and creating one.
|
|
38
|
+
*
|
|
39
|
+
* Every signed-in caller may read the catalog, so the page gates on
|
|
40
|
+
* `can_manage_plans` on `platform:stigmer` itself, and anyone without it
|
|
41
|
+
* sees the designed access notice. Organizations choose their plan under
|
|
42
|
+
* Billing, not here.
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* ```tsx
|
|
46
|
+
* <PlanCatalogConsole />
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
export function PlanCatalogConsole({ className }: PlanCatalogConsoleProps) {
|
|
50
|
+
const available = useResourceAvailable(ApiResourceKind.plan);
|
|
51
|
+
const operator = useCheckPermission(available ? PLATFORM : null, "can_manage_plans", { fail: "closed" });
|
|
52
|
+
const catalog = usePlans({ includeRetired: true, enabled: available && operator.allowed });
|
|
53
|
+
const retirer = useRetirePlan();
|
|
54
|
+
const { confirmState, confirm, handleConfirm, handleCancel } = useConfirmAction();
|
|
55
|
+
const [creating, setCreating] = useState(false);
|
|
56
|
+
|
|
57
|
+
const header = <ConsoleHeader onCreate={operator.allowed && !creating ? () => setCreating(true) : undefined} />;
|
|
58
|
+
|
|
59
|
+
if (!available || (catalog.error && isUnimplemented(catalog.error))) {
|
|
60
|
+
return (
|
|
61
|
+
<div className={cn("stg:space-y-3", className)}>
|
|
62
|
+
{header}
|
|
63
|
+
<CloudFeatureNotice>
|
|
64
|
+
Plans are sold on Stigmer Cloud. The server this console is connected to serves no plan catalog.
|
|
65
|
+
</CloudFeatureNotice>
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
if (operator.isLoading || (operator.allowed && catalog.plans === null && catalog.error === null)) {
|
|
70
|
+
return (
|
|
71
|
+
<div className={cn("stg:space-y-2", className)} aria-busy="true">
|
|
72
|
+
{Array.from({ length: 3 }, (_, i) => (
|
|
73
|
+
<div key={i} className="stg:h-12 stg:animate-pulse stg:rounded-lg stg:bg-muted-subtle" />
|
|
74
|
+
))}
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
if (!operator.allowed) {
|
|
79
|
+
return (
|
|
80
|
+
<div className={cn("stg:space-y-3", className)}>
|
|
81
|
+
{header}
|
|
82
|
+
<PlansAccessNotice />
|
|
83
|
+
</div>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
if (catalog.error) {
|
|
87
|
+
return (
|
|
88
|
+
<div className={cn("stg:space-y-3", className)}>
|
|
89
|
+
{header}
|
|
90
|
+
<p className="stg:text-sm stg:text-destructive" role="alert">
|
|
91
|
+
{getUserMessage(catalog.error)}
|
|
92
|
+
</p>
|
|
93
|
+
<Button variant="outline" onClick={catalog.refetch}>
|
|
94
|
+
Try again
|
|
95
|
+
</Button>
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const retire = async (plan: Plan) => {
|
|
101
|
+
const ok = await confirm({
|
|
102
|
+
title: `Retire ${plan.metadata?.name ?? "this plan"}?`,
|
|
103
|
+
description:
|
|
104
|
+
"It can no longer be bought. Every organization already on it keeps it, invoiced by its terms. Retiring is final.",
|
|
105
|
+
confirmLabel: "Retire",
|
|
106
|
+
variant: "destructive",
|
|
107
|
+
});
|
|
108
|
+
if (ok) {
|
|
109
|
+
retirer.retirePlan(plan.metadata?.id ?? "").then(catalog.refetch, () => undefined);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<div className={cn("stg:space-y-4", className)}>
|
|
115
|
+
{header}
|
|
116
|
+
{creating ? (
|
|
117
|
+
<div className="stg:rounded-lg stg:border stg:border-border stg:bg-card stg:p-4">
|
|
118
|
+
<PlanCreateForm
|
|
119
|
+
onCreated={() => {
|
|
120
|
+
setCreating(false);
|
|
121
|
+
catalog.refetch();
|
|
122
|
+
}}
|
|
123
|
+
onCancel={() => setCreating(false)}
|
|
124
|
+
/>
|
|
125
|
+
</div>
|
|
126
|
+
) : null}
|
|
127
|
+
{retirer.error && (
|
|
128
|
+
<p className="stg:text-xs stg:text-destructive" role="alert">
|
|
129
|
+
{getUserMessage(retirer.error)}
|
|
130
|
+
</p>
|
|
131
|
+
)}
|
|
132
|
+
<ul className={cn(UNSTYLED_LIST, "stg:space-y-2")} aria-label="Plan catalog">
|
|
133
|
+
{(catalog.plans ?? []).map((plan) => (
|
|
134
|
+
<PlanRow key={plan.metadata?.id} plan={plan} onRetire={() => void retire(plan)} busy={retirer.isSubmitting} />
|
|
135
|
+
))}
|
|
136
|
+
</ul>
|
|
137
|
+
<ConfirmDialog state={confirmState} onConfirm={handleConfirm} onCancel={handleCancel} />
|
|
138
|
+
</div>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function ConsoleHeader({ onCreate }: { readonly onCreate?: () => void }) {
|
|
143
|
+
return (
|
|
144
|
+
<div className="stg:flex stg:items-start stg:justify-between stg:gap-3">
|
|
145
|
+
<div>
|
|
146
|
+
<h2 className="stg:text-sm stg:font-semibold stg:text-foreground">Plans</h2>
|
|
147
|
+
<p className="stg:text-xs stg:text-muted-foreground">
|
|
148
|
+
The plans organizations subscribe to. A plan's terms are final; change them with a new plan and retire the
|
|
149
|
+
old one.
|
|
150
|
+
</p>
|
|
151
|
+
</div>
|
|
152
|
+
{onCreate && (
|
|
153
|
+
<Button size="xs" onClick={onCreate}>
|
|
154
|
+
New plan
|
|
155
|
+
</Button>
|
|
156
|
+
)}
|
|
157
|
+
</div>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function PlanRow({ plan, onRetire, busy }: { readonly plan: Plan; readonly onRetire: () => void; readonly busy: boolean }) {
|
|
162
|
+
const active = plan.status?.lifecycle === PlanLifecycle.active;
|
|
163
|
+
const license = plan.spec?.instrument === PlanInstrument.license;
|
|
164
|
+
const terms = plan.spec?.terms;
|
|
165
|
+
const listed = (plan.spec?.entitlements?.features ?? []).filter(isNamedFeature).map((f) => FEATURE_COPY[f].label);
|
|
166
|
+
const offered = new Set(offeredFeatures(plan.spec?.entitlements).map((f) => f.label));
|
|
167
|
+
const managed = formatManagedOrganizations(plan.spec?.entitlements, terms);
|
|
168
|
+
return (
|
|
169
|
+
<li className="stg:rounded-lg stg:border stg:border-border stg:bg-card stg:p-3">
|
|
170
|
+
<div className="stg:flex stg:items-center stg:justify-between stg:gap-3">
|
|
171
|
+
<div className="stg:min-w-0">
|
|
172
|
+
<p className="stg:truncate stg:text-sm stg:font-medium stg:text-foreground">
|
|
173
|
+
{plan.metadata?.name}{" "}
|
|
174
|
+
<span className="stg:font-normal stg:text-muted-foreground">({plan.metadata?.slug})</span>
|
|
175
|
+
</p>
|
|
176
|
+
<p className="stg:text-xs stg:text-muted-foreground">
|
|
177
|
+
{license
|
|
178
|
+
? "Issued as a license"
|
|
179
|
+
: `${formatCreditBalance(terms?.monthlyMinimumMicros ?? BigInt(0))}/month ${formatUsageShare(terms)}`}
|
|
180
|
+
</p>
|
|
181
|
+
</div>
|
|
182
|
+
<div className="stg:flex stg:shrink-0 stg:items-center stg:gap-2">
|
|
183
|
+
<StatusBadge phase={active ? "ready" : "disabled"} label={active ? "On sale" : "Retired"} />
|
|
184
|
+
{active && (
|
|
185
|
+
<Button variant="outline" size="xs" onClick={onRetire} disabled={busy}>
|
|
186
|
+
Retire
|
|
187
|
+
</Button>
|
|
188
|
+
)}
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
191
|
+
<p className="stg:mt-1 stg:text-xs stg:text-muted-foreground">
|
|
192
|
+
{listed.map((label) => (offered.has(label) ? label : `${label} (not offered yet)`)).join(" · ")}
|
|
193
|
+
{managed === "" ? "" : ` · ${managed}`}
|
|
194
|
+
</p>
|
|
195
|
+
</li>
|
|
196
|
+
);
|
|
197
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, type FormEvent } from "react";
|
|
4
|
+
import { cn } from "@stigmer/theme";
|
|
5
|
+
import { getUserMessage } from "@stigmer/sdk";
|
|
6
|
+
import type { Plan } from "@stigmer/protos/ai/stigmer/billing/plan/v1/api_pb";
|
|
7
|
+
import { Feature } from "@stigmer/protos/ai/stigmer/platform/v1/entitlement_pb";
|
|
8
|
+
import { Button } from "../button/index.js";
|
|
9
|
+
import { UNSTYLED_FIELDSET } from "../internal/element-resets.js";
|
|
10
|
+
import { FEATURE_COPY, type NamedFeature } from "../internal/features.js";
|
|
11
|
+
import { Field, INPUT_CLASSES } from "../internal/form-primitives.js";
|
|
12
|
+
import { EMPTY_PLAN_DRAFT, planInputFromDraft, type PlanDraft } from "./plan-draft.js";
|
|
13
|
+
import { useCreatePlan } from "./usePlanMutations.js";
|
|
14
|
+
|
|
15
|
+
/** The features a subscription plan can list, in contract order. */
|
|
16
|
+
const PLAN_FEATURES: readonly NamedFeature[] = [
|
|
17
|
+
Feature.channels,
|
|
18
|
+
Feature.sharing,
|
|
19
|
+
Feature.teams,
|
|
20
|
+
Feature.managed_organizations,
|
|
21
|
+
Feature.byo_provider_keys,
|
|
22
|
+
Feature.sso_enforcement,
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
/** Props for {@link PlanCreateForm}. */
|
|
26
|
+
export interface PlanCreateFormProps {
|
|
27
|
+
/** Fired with the created plan. */
|
|
28
|
+
readonly onCreated: (plan: Plan) => void;
|
|
29
|
+
/** Fired when the operator leaves without creating. */
|
|
30
|
+
readonly onCancel: () => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Adds a subscription plan to the catalog: its name and handle, its
|
|
35
|
+
* monthly minimum and usage share, its features, and the managed
|
|
36
|
+
* organizations it includes. The form states before submitting that a
|
|
37
|
+
* plan's terms are final: a change of terms is a new plan, and the old
|
|
38
|
+
* one is retired.
|
|
39
|
+
*/
|
|
40
|
+
export function PlanCreateForm({ onCreated, onCancel }: PlanCreateFormProps) {
|
|
41
|
+
const creator = useCreatePlan();
|
|
42
|
+
const [draft, setDraft] = useState<PlanDraft>(EMPTY_PLAN_DRAFT);
|
|
43
|
+
const [confirming, setConfirming] = useState(false);
|
|
44
|
+
const result = planInputFromDraft(draft);
|
|
45
|
+
const set = <K extends keyof PlanDraft>(key: K, value: PlanDraft[K]) => {
|
|
46
|
+
setConfirming(false);
|
|
47
|
+
setDraft((current) => ({ ...current, [key]: value }));
|
|
48
|
+
};
|
|
49
|
+
const toggle = (feature: Feature, on: boolean) =>
|
|
50
|
+
set("features", on ? [...draft.features, feature] : draft.features.filter((f) => f !== feature));
|
|
51
|
+
|
|
52
|
+
const submit = (e: FormEvent) => {
|
|
53
|
+
e.preventDefault();
|
|
54
|
+
if (!result.ok) return;
|
|
55
|
+
if (!confirming) {
|
|
56
|
+
setConfirming(true);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
creator.createPlan(result.input).then(onCreated, () => undefined);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const fieldError = (field: keyof PlanDraft) =>
|
|
63
|
+
!result.ok && result.field === field && draft[field] !== "" ? (
|
|
64
|
+
<span className="stg:block stg:text-xs stg:text-destructive">{result.message}</span>
|
|
65
|
+
) : null;
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<form onSubmit={submit} className="stg:space-y-4" aria-label="New plan">
|
|
69
|
+
<div className="stg:grid stg:gap-3 stg:sm:grid-cols-2">
|
|
70
|
+
<Field label="Name" required>
|
|
71
|
+
<input className={INPUT_CLASSES} value={draft.name} onChange={(e) => set("name", e.target.value)} />
|
|
72
|
+
</Field>
|
|
73
|
+
<Field label="Handle (slug)" required>
|
|
74
|
+
<input
|
|
75
|
+
className={INPUT_CLASSES}
|
|
76
|
+
value={draft.slug}
|
|
77
|
+
placeholder="team-2027"
|
|
78
|
+
onChange={(e) => set("slug", e.target.value)}
|
|
79
|
+
/>
|
|
80
|
+
{fieldError("slug")}
|
|
81
|
+
</Field>
|
|
82
|
+
<Field label="Monthly minimum (USD)" required>
|
|
83
|
+
<input
|
|
84
|
+
className={INPUT_CLASSES}
|
|
85
|
+
inputMode="decimal"
|
|
86
|
+
value={draft.monthlyMinimumUsd}
|
|
87
|
+
onChange={(e) => set("monthlyMinimumUsd", e.target.value)}
|
|
88
|
+
/>
|
|
89
|
+
{fieldError("monthlyMinimumUsd")}
|
|
90
|
+
</Field>
|
|
91
|
+
<Field label="Usage share (% of provider cost)" required>
|
|
92
|
+
<input
|
|
93
|
+
className={INPUT_CLASSES}
|
|
94
|
+
inputMode="decimal"
|
|
95
|
+
value={draft.usageSharePercent}
|
|
96
|
+
onChange={(e) => set("usageSharePercent", e.target.value)}
|
|
97
|
+
/>
|
|
98
|
+
{fieldError("usageSharePercent")}
|
|
99
|
+
</Field>
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
<fieldset className={cn(UNSTYLED_FIELDSET, "stg:space-y-2")}>
|
|
103
|
+
<legend className="stg:mb-1 stg:text-xs stg:font-medium stg:text-muted-foreground">Features</legend>
|
|
104
|
+
{PLAN_FEATURES.map((feature) => (
|
|
105
|
+
<label key={feature} className="stg:flex stg:items-start stg:gap-2 stg:text-sm">
|
|
106
|
+
<input
|
|
107
|
+
type="checkbox"
|
|
108
|
+
className="stg:mt-0.5"
|
|
109
|
+
checked={draft.features.includes(feature)}
|
|
110
|
+
onChange={(e) => toggle(feature, e.target.checked)}
|
|
111
|
+
/>
|
|
112
|
+
<span>
|
|
113
|
+
<span className="stg:block stg:text-foreground">{FEATURE_COPY[feature].label}</span>
|
|
114
|
+
<span className="stg:block stg:text-xs stg:text-muted-foreground">
|
|
115
|
+
{FEATURE_COPY[feature].description}
|
|
116
|
+
</span>
|
|
117
|
+
</span>
|
|
118
|
+
</label>
|
|
119
|
+
))}
|
|
120
|
+
</fieldset>
|
|
121
|
+
|
|
122
|
+
{draft.features.includes(Feature.managed_organizations) && (
|
|
123
|
+
<div className="stg:grid stg:gap-3 stg:sm:grid-cols-2">
|
|
124
|
+
<Field label="Managed organizations included (empty: unlimited)">
|
|
125
|
+
<input
|
|
126
|
+
className={INPUT_CLASSES}
|
|
127
|
+
inputMode="numeric"
|
|
128
|
+
value={draft.includedManagedOrganizations}
|
|
129
|
+
onChange={(e) => set("includedManagedOrganizations", e.target.value)}
|
|
130
|
+
/>
|
|
131
|
+
{fieldError("includedManagedOrganizations")}
|
|
132
|
+
</Field>
|
|
133
|
+
<Field label="Each one beyond (USD/month)">
|
|
134
|
+
<input
|
|
135
|
+
className={INPUT_CLASSES}
|
|
136
|
+
inputMode="decimal"
|
|
137
|
+
value={draft.perExtraOrganizationUsd}
|
|
138
|
+
onChange={(e) => set("perExtraOrganizationUsd", e.target.value)}
|
|
139
|
+
/>
|
|
140
|
+
{fieldError("perExtraOrganizationUsd")}
|
|
141
|
+
</Field>
|
|
142
|
+
</div>
|
|
143
|
+
)}
|
|
144
|
+
|
|
145
|
+
{confirming && (
|
|
146
|
+
<p className="stg:rounded-md stg:bg-muted-subtle stg:px-3 stg:py-2 stg:text-xs stg:text-foreground" role="status">
|
|
147
|
+
A plan's terms are final once it exists: every subscriber on it is invoiced by them. A change of terms is
|
|
148
|
+
a new plan, and this one is retired. Create it?
|
|
149
|
+
</p>
|
|
150
|
+
)}
|
|
151
|
+
{creator.error && (
|
|
152
|
+
<p className="stg:text-xs stg:text-destructive" role="alert">
|
|
153
|
+
{getUserMessage(creator.error)}
|
|
154
|
+
</p>
|
|
155
|
+
)}
|
|
156
|
+
|
|
157
|
+
<div className="stg:flex stg:justify-end stg:gap-2">
|
|
158
|
+
<Button variant="ghost" size="sm" type="button" onClick={onCancel} disabled={creator.isSubmitting}>
|
|
159
|
+
Cancel
|
|
160
|
+
</Button>
|
|
161
|
+
<Button size="sm" type="submit" disabled={!result.ok || creator.isSubmitting}>
|
|
162
|
+
{confirming ? "Create plan" : "Review"}
|
|
163
|
+
</Button>
|
|
164
|
+
</div>
|
|
165
|
+
</form>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { EmptyState } from "../empty-state/index.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The access-denied state for the plan catalog's operator surface.
|
|
7
|
+
* Server-side authorization (`can_manage_plans` on `platform:stigmer`) is
|
|
8
|
+
* the real gate; this is the designed face of it for anyone who reaches
|
|
9
|
+
* the route without the permission.
|
|
10
|
+
*/
|
|
11
|
+
export function PlansAccessNotice({ className }: { readonly className?: string }) {
|
|
12
|
+
return (
|
|
13
|
+
<EmptyState
|
|
14
|
+
variant="permission"
|
|
15
|
+
className={className}
|
|
16
|
+
title="Platform operator access required"
|
|
17
|
+
description="The plan catalog is managed by Stigmer's platform operators: a plan's terms are what every subscriber on it is invoiced. Organization roles do not grant access; organizations choose their plan under Billing."
|
|
18
|
+
/>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Plan catalog: the platform-operator console for the Stigmer Cloud plans
|
|
2
|
+
// organizations subscribe to (gated on can_manage_plans). Choosing a plan
|
|
3
|
+
// is the customer-facing billing surface's (../billing).
|
|
4
|
+
|
|
5
|
+
// Behavior hooks
|
|
6
|
+
export { useCreatePlan, useRetirePlan } from "./usePlanMutations.js";
|
|
7
|
+
export type { UseCreatePlanReturn, UseRetirePlanReturn } from "./usePlanMutations.js";
|
|
8
|
+
|
|
9
|
+
// Styled components
|
|
10
|
+
export { PlanCatalogConsole } from "./PlanCatalogConsole.js";
|
|
11
|
+
export type { PlanCatalogConsoleProps } from "./PlanCatalogConsole.js";
|
|
12
|
+
export { PlanCreateForm } from "./PlanCreateForm.js";
|
|
13
|
+
export type { PlanCreateFormProps } from "./PlanCreateForm.js";
|
|
14
|
+
export { PlansAccessNotice } from "./PlansAccessNotice.js";
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// The plan create form's draft and its translation to the SDK's
|
|
2
|
+
// PlanInput: dollars typed by an operator become exact micros, a percent
|
|
3
|
+
// becomes basis points, and every field the contract cannot take is
|
|
4
|
+
// refused here with the field that is wrong, before a request is sent.
|
|
5
|
+
|
|
6
|
+
import type { PlanInput } from "@stigmer/sdk";
|
|
7
|
+
import { PlanInstrument } from "@stigmer/protos/ai/stigmer/billing/plan/v1/spec_pb";
|
|
8
|
+
import { Feature } from "@stigmer/protos/ai/stigmer/platform/v1/entitlement_pb";
|
|
9
|
+
|
|
10
|
+
// BigInt(...), not literals: the docs site compiles this source for a
|
|
11
|
+
// target below ES2020, which has no BigInt literal syntax.
|
|
12
|
+
const MICROS_PER_CENT = BigInt(10_000);
|
|
13
|
+
const CENTS_PER_USD = BigInt(100);
|
|
14
|
+
|
|
15
|
+
/** What an operator types to create a subscription plan. */
|
|
16
|
+
export interface PlanDraft {
|
|
17
|
+
readonly name: string;
|
|
18
|
+
readonly slug: string;
|
|
19
|
+
readonly monthlyMinimumUsd: string;
|
|
20
|
+
readonly usageSharePercent: string;
|
|
21
|
+
readonly features: readonly Feature[];
|
|
22
|
+
/** Empty: the plan includes every managed organization. */
|
|
23
|
+
readonly includedManagedOrganizations: string;
|
|
24
|
+
readonly perExtraOrganizationUsd: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const EMPTY_PLAN_DRAFT: PlanDraft = {
|
|
28
|
+
name: "",
|
|
29
|
+
slug: "",
|
|
30
|
+
monthlyMinimumUsd: "",
|
|
31
|
+
usageSharePercent: "10",
|
|
32
|
+
features: [Feature.channels, Feature.sharing],
|
|
33
|
+
includedManagedOrganizations: "",
|
|
34
|
+
perExtraOrganizationUsd: "",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** The draft as a PlanInput, or the first field that is wrong and why. */
|
|
38
|
+
export type PlanDraftResult =
|
|
39
|
+
| { readonly ok: true; readonly input: PlanInput }
|
|
40
|
+
| { readonly ok: false; readonly field: keyof PlanDraft; readonly message: string };
|
|
41
|
+
|
|
42
|
+
const SLUG = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
43
|
+
|
|
44
|
+
/** A dollar amount with at most two decimals as exact micros, or `null`. */
|
|
45
|
+
export function parseUsd(text: string): bigint | null {
|
|
46
|
+
const match = /^(\d+)(?:\.(\d{1,2}))?$/.exec(text.trim());
|
|
47
|
+
if (match === null) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
const cents = BigInt(match[1] ?? "0") * CENTS_PER_USD + BigInt((match[2] ?? "").padEnd(2, "0"));
|
|
51
|
+
return cents * MICROS_PER_CENT;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A percent with at most two decimals as basis points, or `null`. */
|
|
55
|
+
export function parseBasisPoints(text: string): number | null {
|
|
56
|
+
const match = /^(\d{1,3})(?:\.(\d{1,2}))?$/.exec(text.trim());
|
|
57
|
+
if (match === null) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
const points = Number(match[1]) * 100 + Number((match[2] ?? "").padEnd(2, "0"));
|
|
61
|
+
return points <= 10_000 ? points : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Translate a draft into the create request, refusing what the contract cannot take. */
|
|
65
|
+
export function planInputFromDraft(draft: PlanDraft): PlanDraftResult {
|
|
66
|
+
const name = draft.name.trim();
|
|
67
|
+
if (name === "") {
|
|
68
|
+
return { ok: false, field: "name", message: "A plan needs a name." };
|
|
69
|
+
}
|
|
70
|
+
const slug = draft.slug.trim();
|
|
71
|
+
if (!SLUG.test(slug)) {
|
|
72
|
+
return { ok: false, field: "slug", message: "Lowercase letters, digits and single hyphens, e.g. team-2027." };
|
|
73
|
+
}
|
|
74
|
+
const minimum = parseUsd(draft.monthlyMinimumUsd);
|
|
75
|
+
if (minimum === null) {
|
|
76
|
+
return { ok: false, field: "monthlyMinimumUsd", message: "A dollar amount, e.g. 99 or 99.50." };
|
|
77
|
+
}
|
|
78
|
+
const share = parseBasisPoints(draft.usageSharePercent);
|
|
79
|
+
if (share === null) {
|
|
80
|
+
return { ok: false, field: "usageSharePercent", message: "A percent from 0 to 100, e.g. 10." };
|
|
81
|
+
}
|
|
82
|
+
const managed = draft.features.includes(Feature.managed_organizations);
|
|
83
|
+
let includedManagedOrganizations: number | undefined;
|
|
84
|
+
let perExtraOrganizationMicros: bigint | undefined;
|
|
85
|
+
if (managed && draft.includedManagedOrganizations.trim() !== "") {
|
|
86
|
+
const included = Number(draft.includedManagedOrganizations.trim());
|
|
87
|
+
if (!Number.isInteger(included) || included < 0) {
|
|
88
|
+
return { ok: false, field: "includedManagedOrganizations", message: "A whole number, or empty for unlimited." };
|
|
89
|
+
}
|
|
90
|
+
includedManagedOrganizations = included;
|
|
91
|
+
const fee = parseUsd(draft.perExtraOrganizationUsd === "" ? "0" : draft.perExtraOrganizationUsd);
|
|
92
|
+
if (fee === null) {
|
|
93
|
+
return { ok: false, field: "perExtraOrganizationUsd", message: "A dollar amount per organization, e.g. 25." };
|
|
94
|
+
}
|
|
95
|
+
perExtraOrganizationMicros = fee;
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
ok: true,
|
|
99
|
+
input: {
|
|
100
|
+
name,
|
|
101
|
+
slug,
|
|
102
|
+
org: "",
|
|
103
|
+
instrument: PlanInstrument.subscription,
|
|
104
|
+
entitlements: {
|
|
105
|
+
features: [...new Set(draft.features)].sort((a, b) => a - b),
|
|
106
|
+
...(includedManagedOrganizations === undefined ? {} : { limits: { includedManagedOrganizations } }),
|
|
107
|
+
},
|
|
108
|
+
terms: {
|
|
109
|
+
monthlyMinimumMicros: minimum,
|
|
110
|
+
usageShareBasisPoints: share,
|
|
111
|
+
...(perExtraOrganizationMicros === undefined ? {} : { perExtraOrganizationMicros }),
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useMemo, useState } from "react";
|
|
4
|
+
import type { PlanInput } from "@stigmer/sdk";
|
|
5
|
+
import type { Plan } from "@stigmer/protos/ai/stigmer/billing/plan/v1/api_pb";
|
|
6
|
+
import { useStigmer } from "../hooks.js";
|
|
7
|
+
import { toError } from "../internal/toError.js";
|
|
8
|
+
|
|
9
|
+
/** Return value of {@link useCreatePlan}. */
|
|
10
|
+
export interface UseCreatePlanReturn {
|
|
11
|
+
/**
|
|
12
|
+
* Add a plan to the catalog. A plan's terms are final once it exists:
|
|
13
|
+
* there is no update, so changed terms are a new plan and the old one
|
|
14
|
+
* is retired.
|
|
15
|
+
*/
|
|
16
|
+
readonly createPlan: (input: PlanInput) => Promise<Plan>;
|
|
17
|
+
/** `true` while a create is in flight. */
|
|
18
|
+
readonly isSubmitting: boolean;
|
|
19
|
+
/** Error from the last failed attempt, or `null` when healthy. */
|
|
20
|
+
readonly error: Error | null;
|
|
21
|
+
/** Reset `error` to `null`. */
|
|
22
|
+
readonly clearError: () => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Behaviour hook that adds a plan to the Stigmer Cloud catalog.
|
|
27
|
+
* Platform-operator surface: `can_manage_plans` on `platform:stigmer`.
|
|
28
|
+
*/
|
|
29
|
+
export function useCreatePlan(): UseCreatePlanReturn {
|
|
30
|
+
const stigmer = useStigmer();
|
|
31
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
32
|
+
const [error, setError] = useState<Error | null>(null);
|
|
33
|
+
const createPlan = useCallback(
|
|
34
|
+
async (input: PlanInput): Promise<Plan> => {
|
|
35
|
+
setIsSubmitting(true);
|
|
36
|
+
setError(null);
|
|
37
|
+
try {
|
|
38
|
+
return await stigmer.plan.create(input);
|
|
39
|
+
} catch (e) {
|
|
40
|
+
const err = toError(e);
|
|
41
|
+
setError(err);
|
|
42
|
+
throw err;
|
|
43
|
+
} finally {
|
|
44
|
+
setIsSubmitting(false);
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
[stigmer.plan],
|
|
48
|
+
);
|
|
49
|
+
const clearError = useCallback(() => setError(null), []);
|
|
50
|
+
return useMemo(() => ({ createPlan, isSubmitting, error, clearError }), [createPlan, isSubmitting, error, clearError]);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Return value of {@link useRetirePlan}. */
|
|
54
|
+
export interface UseRetirePlanReturn {
|
|
55
|
+
/**
|
|
56
|
+
* Retire a plan: it can no longer be bought, and every subscription
|
|
57
|
+
* already on it keeps it. Retiring is final.
|
|
58
|
+
*/
|
|
59
|
+
readonly retirePlan: (planId: string) => Promise<Plan>;
|
|
60
|
+
/** `true` while a retire is in flight. */
|
|
61
|
+
readonly isSubmitting: boolean;
|
|
62
|
+
/** Error from the last failed attempt, or `null` when healthy. */
|
|
63
|
+
readonly error: Error | null;
|
|
64
|
+
/** Reset `error` to `null`. */
|
|
65
|
+
readonly clearError: () => void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Behaviour hook that retires a catalog plan.
|
|
70
|
+
* Platform-operator surface: `can_manage_plans` on `platform:stigmer`.
|
|
71
|
+
*/
|
|
72
|
+
export function useRetirePlan(): UseRetirePlanReturn {
|
|
73
|
+
const stigmer = useStigmer();
|
|
74
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
75
|
+
const [error, setError] = useState<Error | null>(null);
|
|
76
|
+
const retirePlan = useCallback(
|
|
77
|
+
async (planId: string): Promise<Plan> => {
|
|
78
|
+
setIsSubmitting(true);
|
|
79
|
+
setError(null);
|
|
80
|
+
try {
|
|
81
|
+
return await stigmer.plan.retire(planId);
|
|
82
|
+
} catch (e) {
|
|
83
|
+
const err = toError(e);
|
|
84
|
+
setError(err);
|
|
85
|
+
throw err;
|
|
86
|
+
} finally {
|
|
87
|
+
setIsSubmitting(false);
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
[stigmer.plan],
|
|
91
|
+
);
|
|
92
|
+
const clearError = useCallback(() => setError(null), []);
|
|
93
|
+
return useMemo(() => ({ retirePlan, isSubmitting, error, clearError }), [retirePlan, isSubmitting, error, clearError]);
|
|
94
|
+
}
|
|
@@ -8,9 +8,11 @@ import {
|
|
|
8
8
|
type ChannelAppCreateHandoff,
|
|
9
9
|
} from "../channel-app/CreateChannelAppForm.js";
|
|
10
10
|
import { ChannelAppDetailPanel } from "../channel-app/ChannelAppDetailPanel.js";
|
|
11
|
+
import { CHANNEL_APPS_MANAGED_BY_ADMINS } from "../channel-app/copy.js";
|
|
11
12
|
import { useDeploymentMode } from "../deployment-mode.js";
|
|
13
|
+
import { useCheckPermission } from "../iam-policy/useCheckPermission.js";
|
|
12
14
|
import { CloudFeatureNotice } from "../internal/CloudFeatureNotice.js";
|
|
13
|
-
import { useActiveOrgSlug } from "../organization/OrgProvider.js";
|
|
15
|
+
import { useActiveOrgId, useActiveOrgSlug } from "../organization/OrgProvider.js";
|
|
14
16
|
|
|
15
17
|
type FlowState =
|
|
16
18
|
| { phase: "idle" }
|
|
@@ -36,13 +38,27 @@ type FlowState =
|
|
|
36
38
|
*
|
|
37
39
|
* After registering an app here, the connect dialog on any agent's
|
|
38
40
|
* Channels tab offers it as the serving app.
|
|
41
|
+
*
|
|
42
|
+
* "New channel app" is offered once the server confirms the caller holds
|
|
43
|
+
* `can_create_channel_app` on the organization. The list shows only the
|
|
44
|
+
* apps the caller may view (their creator, anyone granted viewer on one,
|
|
45
|
+
* and the organization's admins), so a caller who may not create channel
|
|
46
|
+
* apps is told that the organization's admins manage them rather than that
|
|
47
|
+
* none is registered.
|
|
39
48
|
*/
|
|
40
49
|
export function ChannelAppsSection() {
|
|
41
50
|
const headingId = useId();
|
|
42
51
|
const org = useActiveOrgSlug();
|
|
52
|
+
const orgId = useActiveOrgId();
|
|
43
53
|
// Channel installs (the consumer of these credentials) are cloud-only;
|
|
44
54
|
// gate the whole section the way the Channels tab gates connects.
|
|
45
55
|
const installsAvailable = useDeploymentMode() === "cloud";
|
|
56
|
+
const createCheck = useCheckPermission(
|
|
57
|
+
installsAvailable && orgId ? { kind: "organization", id: orgId } : null,
|
|
58
|
+
"can_create_channel_app",
|
|
59
|
+
);
|
|
60
|
+
const canCreate = !createCheck.isLoading && createCheck.allowed;
|
|
61
|
+
const deniedCreate = !createCheck.isLoading && !createCheck.allowed;
|
|
46
62
|
|
|
47
63
|
const [flow, setFlow] = useState<FlowState>({ phase: "idle" });
|
|
48
64
|
const listRefetchRef = useRef<(() => void) | null>(null);
|
|
@@ -82,7 +98,7 @@ export function ChannelAppsSection() {
|
|
|
82
98
|
Channel Apps
|
|
83
99
|
</h2>
|
|
84
100
|
|
|
85
|
-
{installsAvailable && org && flow.phase === "idle" && (
|
|
101
|
+
{installsAvailable && org && canCreate && flow.phase === "idle" && (
|
|
86
102
|
<button
|
|
87
103
|
type="button"
|
|
88
104
|
onClick={() => setFlow({ phase: "creating" })}
|
|
@@ -127,6 +143,7 @@ export function ChannelAppsSection() {
|
|
|
127
143
|
org={org}
|
|
128
144
|
onEdit={(app) => setFlow({ phase: "editing", channelApp: app })}
|
|
129
145
|
onRefetchRef={handleRefetchRef}
|
|
146
|
+
emptyState={deniedCreate ? CHANNEL_APPS_MANAGED_BY_ADMINS : undefined}
|
|
130
147
|
/>
|
|
131
148
|
)}
|
|
132
149
|
</section>
|