Merge branch 'main' into dev

This commit is contained in:
John Yeo
2026-06-15 10:25:07 +01:00
committed by GitHub
9 changed files with 565 additions and 302 deletions

View File

@@ -44,7 +44,12 @@
"vite": { "vite": {
"entry": ["tests/**/*.{ts,tsx}", "src/**/*.test.{ts,tsx}"], "entry": ["tests/**/*.{ts,tsx}", "src/**/*.test.{ts,tsx}"],
"project": ["src/**/*.{ts,tsx}", "tests/**/*.{ts,tsx}"], "project": ["src/**/*.{ts,tsx}", "tests/**/*.{ts,tsx}"],
"ignore": ["src/components/ai-elements/**", "src/hooks/useControllableState.ts", "src/types/**/*.d.ts"], "ignore": [
"src/components/ai-elements/**",
"src/components/ui/scroll-area.tsx",
"src/hooks/useControllableState.ts",
"src/types/**/*.d.ts"
],
"ignoreDependencies": [ "ignoreDependencies": [
"tailwindcss", "tailwindcss",
"tailwind-scrollbar-hide", "tailwind-scrollbar-hide",

View File

@@ -1,15 +1,27 @@
import { LEAF_OAUTH_SCOPES } from "@autumn/shared/leafOAuthScopes"; import { LEAF_OAUTH_SCOPES } from "@autumn/shared/leafOAuthScopes";
import type { ScopeString } from "@autumn/shared/scopeDefinitions"; import {
LEGACY_SCOPE_ALIASES,
OPENID_SCOPES,
} from "@autumn/shared/scopeDefinitions";
const leafScopeSet = new Set<string>(LEAF_OAUTH_SCOPES); const leafScopeSet = new Set<string>(LEAF_OAUTH_SCOPES);
const oauthPassthroughScopeSet = new Set<string>(["offline_access"]);
const oauthProtocolScopeSet = new Set<string>(OPENID_SCOPES);
export const getDefaultOAuthScopes = (requestedScopes?: string[] | null) => { export const getDefaultOAuthScopes = (requestedScopes?: string[] | null) => {
const requested = const requested =
requestedScopes && requestedScopes.length > 0 requestedScopes && requestedScopes.length > 0
? requestedScopes ? requestedScopes
: [...LEAF_OAUTH_SCOPES]; : [...LEAF_OAUTH_SCOPES, ...oauthPassthroughScopeSet];
return [...new Set(requested)].filter((scope): scope is ScopeString => // Issued scopes must echo the client's request verbatim (better-auth
leafScopeSet.has(scope), // rejects rewrites), so legacy CRUDL aliases are used for filtering only.
return [...new Set(requested)].filter(
(scope) =>
leafScopeSet.has(LEGACY_SCOPE_ALIASES[scope] ?? scope) ||
oauthPassthroughScopeSet.has(scope),
); );
}; };
export const getOAuthResourceScopes = <T extends string>(scopes: readonly T[]) =>
scopes.filter((scope) => !oauthProtocolScopeSet.has(scope));

View File

@@ -1,5 +1,6 @@
import { prefixOAuthToken } from "@autumn/auth"; import { prefixOAuthToken } from "@autumn/auth";
import { import {
getOAuthResourceScopes,
getResourceFromOAuthTokenRequest, getResourceFromOAuthTokenRequest,
returnsOAuthAccessTokenForClientId, returnsOAuthAccessTokenForClientId,
} from "@autumn/auth/oauth"; } from "@autumn/auth/oauth";
@@ -123,7 +124,10 @@ export const handleOAuthTokenWithApiKey = async (c: Context) => {
const accessToken = getString(tokenPayload.access_token); const accessToken = getString(tokenPayload.access_token);
if (!accessToken) return response; if (!accessToken) return response;
const requestedScopes = scopesFromOAuthScopeString(tokenPayload.scope); const parsedRequestedScopes = scopesFromOAuthScopeString(tokenPayload.scope);
const requestedScopes = parsedRequestedScopes
? getOAuthResourceScopes(parsedRequestedScopes)
: null;
let apiKeyResult: Awaited<ReturnType<typeof getExternalOAuthApiKeyForToken>>; let apiKeyResult: Awaited<ReturnType<typeof getExternalOAuthApiKeyForToken>>;
try { try {
const tokenRecord = await getOAuthAccessTokenRecord({ const tokenRecord = await getOAuthAccessTokenRecord({
@@ -142,15 +146,15 @@ export const handleOAuthTokenWithApiKey = async (c: Context) => {
const issuedScopes = await getOAuthConsentScopeGrant({ const issuedScopes = await getOAuthConsentScopeGrant({
db, db,
organizationId: tokenRecord.referenceId, organizationId: tokenRecord.referenceId,
requestedScopes: tokenRecord.scopes, requestedScopes: parsedRequestedScopes ?? tokenRecord.scopes,
userId: tokenRecord.userId, userId: tokenRecord.userId,
}); });
tokenRecord.scopes = issuedScopes; tokenRecord.scopes = getOAuthResourceScopes(issuedScopes);
if (tokenRecord.id) { if (tokenRecord.id) {
await oauthAccessTokenRepo.updateScopes({ await oauthAccessTokenRepo.updateScopes({
db, db,
id: tokenRecord.id, id: tokenRecord.id,
scopes: issuedScopes, scopes: tokenRecord.scopes,
}); });
} }
if (tokenRecord.refreshId) { if (tokenRecord.refreshId) {

View File

@@ -1,4 +1,7 @@
import { getDefaultOAuthScopes } from "@autumn/auth/oauth"; import {
getDefaultOAuthScopes,
getOAuthResourceScopes,
} from "@autumn/auth/oauth";
import { ErrCode, isScopeSubset, RecaseError } from "@autumn/shared"; import { ErrCode, isScopeSubset, RecaseError } from "@autumn/shared";
import type { DrizzleCli } from "@/db/initDrizzle.js"; import type { DrizzleCli } from "@/db/initDrizzle.js";
import { getScopesForUserInOrg } from "@/utils/authUtils/customSessionScopes.js"; import { getScopesForUserInOrg } from "@/utils/authUtils/customSessionScopes.js";
@@ -21,14 +24,21 @@ export const getOAuthConsentScopeGrant = async ({
organizationId, organizationId,
}); });
const grant = finalRequestedScopes.filter((scope) => const resourceScopes = getOAuthResourceScopes(finalRequestedScopes);
const resourceGrant = resourceScopes.filter((scope) =>
isScopeSubset([scope], userScopes), isScopeSubset([scope], userScopes),
); );
if (grant.length > 0) return grant; if (resourceGrant.length === 0) {
throw new RecaseError({ throw new RecaseError({
message: "No requested scopes can be granted to this OAuth client", message: "No requested scopes can be granted to this OAuth client",
code: ErrCode.InsufficientScopes, code: ErrCode.InsufficientScopes,
statusCode: 403, statusCode: 403,
}); });
}
const grantedResourceScopes = new Set(resourceGrant);
return finalRequestedScopes.filter(
(scope) =>
!resourceScopes.includes(scope) || grantedResourceScopes.has(scope),
);
}; };

View File

@@ -1,4 +1,10 @@
import { AppEnv, ErrCode, RecaseError, Scopes } from "@autumn/shared"; import {
AppEnv,
ErrCode,
LEGACY_SCOPE_ALIASES,
RecaseError,
Scopes,
} from "@autumn/shared";
import { createRoute } from "@/honoMiddlewares/routeHandler.js"; import { createRoute } from "@/honoMiddlewares/routeHandler.js";
import { isMcpOAuthClientId } from "@/internal/auth/oauth/mcpOAuthScopes.js"; import { isMcpOAuthClientId } from "@/internal/auth/oauth/mcpOAuthScopes.js";
import { import {
@@ -84,7 +90,15 @@ export const handleCreateOAuthApiKeys = createRoute({
statusCode: 401, statusCode: 401,
}); });
} }
const apiKeyScopes = requestedScopes ?? tokenRecord.scopes; // Tokens may carry legacy CRUDL scopes (old CLI); store modern R/W on keys.
const apiKeyScopes = [
...new Set(
(requestedScopes ?? tokenRecord.scopes).map(
(scope) => LEGACY_SCOPE_ALIASES[scope] ?? scope,
),
),
];
if (await isMcpOAuthClientId({ clientId, ctx })) { if (await isMcpOAuthClientId({ clientId, ctx })) {
throw new RecaseError({ throw new RecaseError({
message: "MCP OAuth clients must use OAuth access tokens directly", message: "MCP OAuth clients must use OAuth access tokens directly",

View File

@@ -1,46 +1,105 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { getDefaultOAuthScopes } from "@autumn/auth/oauth"; import {
getDefaultOAuthScopes,
getOAuthResourceScopes,
} from "@autumn/auth/oauth";
import { LEAF_OAUTH_SCOPES } from "@autumn/shared"; import { LEAF_OAUTH_SCOPES } from "@autumn/shared";
import { Scopes } from "@autumn/shared/scopeDefinitions"; import { Scopes } from "@autumn/shared/scopeDefinitions";
import { getRequestedScopesForMcpClient } from "@/internal/auth/actions/registerMcpOAuthClient.js"; import { getRequestedScopesForMcpClient } from "@/internal/auth/actions/registerMcpOAuthClient.js";
const OFFLINE_ACCESS_SCOPE = "offline_access";
const DEFAULT_MCP_SCOPES = [...LEAF_OAUTH_SCOPES, OFFLINE_ACCESS_SCOPE];
describe("getRequestedScopesForMcpClient", () => { describe("getRequestedScopesForMcpClient", () => {
test("defaults Slack MCP clients to Leaf OAuth scopes", () => { test("defaults Slack MCP clients to Leaf scopes plus offline access", () => {
expect( expect(
getRequestedScopesForMcpClient({ clientType: "slack", scope: undefined }), getRequestedScopesForMcpClient({ clientType: "slack", scope: undefined }),
).toEqual([...LEAF_OAUTH_SCOPES]); ).toEqual(DEFAULT_MCP_SCOPES);
}); });
test("defaults Codex MCP clients to Leaf OAuth scopes", () => { test("defaults Codex MCP clients to Leaf scopes plus offline access", () => {
expect( expect(
getRequestedScopesForMcpClient({ clientType: "codex", scope: undefined }), getRequestedScopesForMcpClient({ clientType: "codex", scope: undefined }),
).toEqual([...LEAF_OAUTH_SCOPES]); ).toEqual(DEFAULT_MCP_SCOPES);
}); });
test("defaults dynamic MCP clients to Leaf OAuth scopes", () => { test("defaults dynamic MCP clients to Leaf scopes plus offline access", () => {
expect( expect(
getRequestedScopesForMcpClient({ getRequestedScopesForMcpClient({
clientType: "dynamic", clientType: "dynamic",
scope: undefined, scope: undefined,
}), }),
).toEqual([...LEAF_OAUTH_SCOPES]); ).toEqual(DEFAULT_MCP_SCOPES);
}); });
test("caps explicit requested scopes to Leaf scopes", () => { test("caps explicit requested scopes to Leaf scopes plus offline access", () => {
expect( expect(
getRequestedScopesForMcpClient({ getRequestedScopesForMcpClient({
clientType: "slack", clientType: "slack",
scope: `${Scopes.Customers.Read} ${Scopes.Plans.Write} ${Scopes.ApiKeys.Write} invalid`, scope: `${Scopes.Customers.Read} ${Scopes.Plans.Write} ${Scopes.ApiKeys.Write} ${OFFLINE_ACCESS_SCOPE} invalid`,
}), }),
).toEqual([Scopes.Customers.Read, Scopes.Plans.Write]); ).toEqual([
Scopes.Customers.Read,
Scopes.Plans.Write,
OFFLINE_ACCESS_SCOPE,
]);
}); });
test("caps OAuth grants to Leaf scopes", () => { test("preserves offline access in default OAuth scopes", () => {
expect(getDefaultOAuthScopes()).toEqual(DEFAULT_MCP_SCOPES);
});
test("caps OAuth grants to Leaf scopes plus offline access", () => {
expect( expect(
getDefaultOAuthScopes([ getDefaultOAuthScopes([
Scopes.Customers.Read, Scopes.Customers.Read,
Scopes.ApiKeys.Write, Scopes.ApiKeys.Write,
Scopes.Analytics.Read, Scopes.Analytics.Read,
OFFLINE_ACCESS_SCOPE,
]),
).toEqual([
Scopes.Customers.Read,
Scopes.Analytics.Read,
OFFLINE_ACCESS_SCOPE,
]);
});
test("keeps legacy CRUDL scopes (old CLI) whose alias is leaf-allowed, verbatim", () => {
expect(
getDefaultOAuthScopes([
"customers:create",
"customers:read",
"customers:list",
"customers:update",
"customers:delete",
"features:create",
"features:read",
"plans:update",
"apiKeys:create",
"organisation:read",
]),
).toEqual([
"customers:create",
"customers:read",
"customers:list",
"customers:update",
"customers:delete",
"features:create",
"features:read",
"plans:update",
"organisation:read",
]);
});
test("strips OAuth protocol scopes from resource scopes", () => {
expect(
getOAuthResourceScopes([
Scopes.Customers.Read,
OFFLINE_ACCESS_SCOPE,
"openid",
"profile",
"email",
Scopes.Analytics.Read,
]), ]),
).toEqual([Scopes.Customers.Read, Scopes.Analytics.Read]); ).toEqual([Scopes.Customers.Read, Scopes.Analytics.Read]);
}); });

View File

@@ -1,8 +1,16 @@
import type { ProductItem } from "@autumn/shared"; import type { ProductItem, ProductV2 } from "@autumn/shared";
import { Check, X } from "@phosphor-icons/react"; import { PackageIcon, XIcon } from "@phosphor-icons/react";
import { IconButton } from "@/components/v2/buttons/IconButton"; import { Checkbox } from "@/components/v2/checkboxes/Checkbox";
import { SelectGroup, SelectLabel } from "@/components/v2/selects/Select"; import {
import { TagSelect } from "@/components/v2/selects/TagSelect"; DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/v2/dropdowns/DropdownMenu";
import { useOrg } from "@/hooks/common/useOrg"; import { useOrg } from "@/hooks/common/useOrg";
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery"; import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
import { useProductsByPriceIdsQuery } from "@/hooks/queries/useProductsByPriceIdsQuery"; import { useProductsByPriceIdsQuery } from "@/hooks/queries/useProductsByPriceIdsQuery";
@@ -11,11 +19,19 @@ import { isFeatureItem } from "@/utils/product/getItemType";
import { formatProductItemText } from "@/utils/product/product-item/formatProductItem"; import { formatProductItemText } from "@/utils/product/product-item/formatProductItem";
import type { FrontendReward } from "../../types/frontendReward"; import type { FrontendReward } from "../../types/frontendReward";
const MAX_VISIBLE_CHIPS = 3;
interface ProductPriceSelectorProps { interface ProductPriceSelectorProps {
reward: FrontendReward; reward: FrontendReward;
setReward: (reward: FrontendReward) => void; setReward: (reward: FrontendReward) => void;
} }
const priceItemsOf = (product: ProductV2) =>
(product.items ?? []).filter(
(item): item is ProductItem & { price_id: string } =>
!isFeatureItem(item) && Boolean(item.price_id),
);
export function ProductPriceSelector({ export function ProductPriceSelector({
reward, reward,
setReward, setReward,
@@ -25,175 +41,268 @@ export function ProductPriceSelector({
const { features } = useFeaturesQuery(); const { features } = useFeaturesQuery();
const config = reward.discount_config!; const config = reward.discount_config!;
const priceIds = config.price_ids ?? [];
const applyToAll = config.apply_to_all ?? false;
const linkedPriceIds = config.price_ids ?? []; // Selected price IDs may belong to historical versions absent from the
const { products: linkedProductVersions } = // latest-versions list; resolve their owning product for chip labels.
useProductsByPriceIdsQuery(linkedPriceIds); const { products: linkedProductVersions, isLoading: linkedVersionsLoading } =
useProductsByPriceIdsQuery(priceIds);
const setConfig = (key: string, value: any) => { const setPriceIds = (nextPriceIds: string[]) =>
setReward({
...reward,
discount_config: { ...config, [key]: value },
});
};
const handlePriceToggle = (priceId: string) => {
const currentPriceIds = config.price_ids || [];
let newPriceIds: string[];
if (currentPriceIds.includes(priceId)) {
newPriceIds = currentPriceIds.filter((id) => id !== priceId);
} else {
newPriceIds = [...currentPriceIds, priceId];
}
// If selecting a specific price, clear apply_to_all
setReward({ setReward({
...reward, ...reward,
discount_config: { discount_config: {
...config, ...config,
apply_to_all: false, apply_to_all: false,
price_ids: newPriceIds, price_ids: nextPriceIds,
}, },
}); });
};
const handleApplyToAllToggle = () => { const toggleApplyToAll = () =>
const newApplyToAll = !config.apply_to_all;
if (newApplyToAll) {
// Enabling "Apply to all" clears price_ids
setReward({ setReward({
...reward, ...reward,
discount_config: { discount_config: {
...config, ...config,
apply_to_all: true, apply_to_all: !applyToAll,
price_ids: [], price_ids: [],
}, },
}); });
} else {
// Disabling "Apply to all" just sets it to false const togglePrice = (priceId: string) =>
setConfig("apply_to_all", false); setPriceIds(
} priceIds.includes(priceId)
? priceIds.filter((id) => id !== priceId)
: [...priceIds, priceId],
);
const toggleProduct = (product: ProductV2) => {
const ids = priceItemsOf(product).map((item) => item.price_id);
const allSelected = ids.every((id) => priceIds.includes(id));
setPriceIds(
allSelected
? priceIds.filter((id) => !ids.includes(id))
: [...priceIds, ...ids.filter((id) => !priceIds.includes(id))],
);
}; };
const formatPriceTag = (priceId: string) => { const availableProducts = products.filter(
const product = linkedProductVersions.find((p: any) => (product) => priceItemsOf(product).length > 0,
p.items.find((i: any) => i.price_id === priceId),
); );
const item = product?.items.find((i: any) => i.price_id === priceId);
if (!item || !product) return "Unknown Price"; // Prefer latest versions already on the client; fall back to the async
// query only for prices owned by historical versions.
const productVersionOf = (priceId: string) =>
[...products, ...linkedProductVersions].find((product) =>
product.items?.some((item) => item.price_id === priceId),
);
const chipLabel = (priceId: string) => {
const product = productVersionOf(priceId);
const item = product?.items?.find((i) => i.price_id === priceId);
if (!item || !product)
return linkedVersionsLoading ? "Loading…" : "Unknown price";
const priceText = formatProductItemText({ item, org, features }); const priceText = formatProductItemText({ item, org, features });
return `${product.name} v${product.version}${priceText}`; return `${product.name} v${product.version}${priceText}`;
}; };
// Get products with non-feature items type Chip = { key: string; label: string; onRemove?: () => void };
const availableProducts = products.filter((product: any) => {
const nonFeatureItems = product.items?.filter( // Collapse a product's prices into a single product chip when all are selected.
(item: ProductItem) => !isFeatureItem(item), const buildChips = (): Chip[] => {
); if (applyToAll) return [{ key: "__all__", label: "All products" }];
return nonFeatureItems && nonFeatureItems.length > 0;
const chips: Chip[] = [];
const seenProducts = new Set<string>();
for (const priceId of priceIds) {
const product = productVersionOf(priceId);
const productPriceIds = product
? priceItemsOf(product).map((item) => item.price_id)
: [];
const allPricesSelected =
productPriceIds.length > 0 &&
productPriceIds.every((id) => priceIds.includes(id));
if (product && allPricesSelected) {
const productKey = `${product.id}:${product.version}`;
if (seenProducts.has(productKey)) continue;
seenProducts.add(productKey);
chips.push({
key: productKey,
label: product.name,
onRemove: () =>
setPriceIds(priceIds.filter((id) => !productPriceIds.includes(id))),
}); });
continue;
if (!products || products.length === 0) {
return <p className="text-sm text-tertiary-foreground">No products available</p>;
} }
// Build options list - not used for display, just for reference chips.push({
const priceOptions = availableProducts.flatMap((product: any) => { key: priceId,
const nonFeatureItems = product.items.filter( label: chipLabel(priceId),
(item: ProductItem) => !isFeatureItem(item), onRemove: () => togglePrice(priceId),
);
return nonFeatureItems.map((item: any) => ({
value: item.price_id,
label: formatProductItemText({ item, org, features }),
}));
}); });
return (
<TagSelect
value={config.price_ids || []}
onChange={(values) => setConfig("price_ids", values)}
options={priceOptions}
placeholder="Select plans or apply to all"
formatTag={formatPriceTag}
showAllProducts={config.apply_to_all}
renderContent={(setOpen) => (
<>
{/* Apply to all option */}
<SelectGroup>
<div
role="button"
tabIndex={0}
className="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 px-2 text-sm outline-none hover:bg-accent hover:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 cursor-pointer"
onClick={() => {
handleApplyToAllToggle();
setOpen(false);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleApplyToAllToggle();
setOpen(false);
} }
}} return chips;
> };
<div className="flex items-center justify-between w-full">
<span>Apply to all products</span>
{config.apply_to_all && (
<Check size={14} className="text-primary ml-2" />
)}
</div>
</div>
</SelectGroup>
{/* Product groups */} if (!products || products.length === 0)
{availableProducts.map((product: any) => { return (
const nonFeatureItems = product.items.filter( <p className="text-sm text-tertiary-foreground">No products available</p>
(item: ProductItem) => !isFeatureItem(item),
); );
const chips = buildChips();
return ( return (
<SelectGroup key={product.id}> <div className="min-w-0 w-full">
<SelectLabel>{product.name}</SelectLabel> <DropdownMenu>
{nonFeatureItems.map((item: any) => { <DropdownMenuTrigger className="flex h-8 w-full min-w-0 cursor-pointer items-center gap-1.5 overflow-hidden rounded-xl px-3 input-base input-state-open-tiny text-sm">
const isSelected = config.price_ids?.includes(item.price_id); {chips.length === 0 ? (
return ( <span className="text-tertiary-foreground">
<div Select plans or apply to all...
key={item.price_id} </span>
role="button" ) : (
tabIndex={0} <>
className="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 px-2 text-sm outline-none hover:bg-accent hover:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 cursor-pointer" {chips.slice(0, MAX_VISIBLE_CHIPS).map((chip) => (
onClick={() => handlePriceToggle(item.price_id)} <span
onKeyDown={(e) => { className="flex h-4.5 max-w-48 shrink-0 items-center gap-0.5 rounded border border-border bg-accent px-1 text-[10px] text-foreground"
if (e.key === "Enter" || e.key === " ") { key={chip.key}
e.preventDefault(); >
handlePriceToggle(item.price_id); <span className="shrink-0 [&_svg]:size-3">
} <PackageIcon
}} className="text-tertiary-foreground"
> size={12}
<div className="flex items-center justify-between w-full"> weight="duotone"
<span className="truncate"> />
{formatProductItemText({ </span>
item, <span className="truncate">{chip.label}</span>
org, {chip.onRemove && (
features, <span
})} className="ml-0.5 cursor-pointer text-tertiary-foreground hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
chip.onRemove?.();
}}
onPointerDown={(e) => e.stopPropagation()}
>
<XIcon size={10} />
</span>
)}
</span>
))}
{chips.length > MAX_VISIBLE_CHIPS && (
<span className="shrink-0 px-1 text-sm text-tertiary-foreground">
+{chips.length - MAX_VISIBLE_CHIPS}
</span> </span>
{isSelected && (
<Check size={14} className="text-primary ml-2" />
)} )}
</div>
</div>
);
})}
</SelectGroup>
);
})}
</> </>
)} )}
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64">
<DropdownMenuItem
className="flex cursor-pointer items-center gap-2 font-medium"
closeOnClick={false}
onClick={(e) => {
e.preventDefault();
toggleApplyToAll();
}}
>
<Checkbox checked={applyToAll} className="border-border" />
<span className="truncate">Apply to all products</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<div className="max-h-72 overflow-y-auto">
{availableProducts.map((product) => {
const priceItems = priceItemsOf(product);
if (priceItems.length === 1) {
const priceId = priceItems[0].price_id;
return (
<DropdownMenuItem
className="flex cursor-pointer items-center gap-2 font-medium"
closeOnClick={false}
key={product.id}
onClick={(e) => {
e.preventDefault();
togglePrice(priceId);
}}
>
<Checkbox
checked={!applyToAll && priceIds.includes(priceId)}
className="border-border"
/> />
<span className="truncate">{product.name}</span>
</DropdownMenuItem>
);
}
const ids = priceItems.map((item) => item.price_id);
const selectedCount = ids.filter((id) =>
priceIds.includes(id),
).length;
const allSelected = !applyToAll && selectedCount === ids.length;
const someSelected = !applyToAll && selectedCount > 0;
return (
<DropdownMenuSub key={product.id}>
<DropdownMenuSubTrigger
className="flex cursor-pointer items-center gap-2 font-medium"
onClick={(e) => {
e.preventDefault();
toggleProduct(product);
}}
>
<Checkbox
checked={allSelected}
indeterminate={someSelected && !allSelected}
className="border-border"
/>
<span className="truncate">{product.name}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem
className="flex cursor-pointer items-center gap-2 font-medium"
closeOnClick={false}
onClick={(e) => {
e.preventDefault();
toggleProduct(product);
}}
>
<Checkbox
checked={allSelected}
indeterminate={someSelected && !allSelected}
className="border-border"
/>
All prices
</DropdownMenuItem>
<DropdownMenuSeparator />
{priceItems.map((item) => (
<DropdownMenuItem
className="flex cursor-pointer items-center gap-2 text-sm"
closeOnClick={false}
key={item.price_id}
onClick={(e) => {
e.preventDefault();
togglePrice(item.price_id);
}}
>
<Checkbox
checked={
!applyToAll && priceIds.includes(item.price_id)
}
className="border-border"
/>
<span className="truncate">
{formatProductItemText({ item, org, features })}
</span>
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
);
})}
</div>
</DropdownMenuContent>
</DropdownMenu>
</div>
); );
} }

View File

@@ -3,6 +3,14 @@ import type { AxiosError } from "axios";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { ShortcutButton } from "@/components/v2/buttons/ShortcutButton"; import { ShortcutButton } from "@/components/v2/buttons/ShortcutButton";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/v2/dialogs/Dialog";
import { import {
SheetFooter, SheetFooter,
SheetHeader, SheetHeader,
@@ -40,6 +48,7 @@ export function UpdateRewardSheet({
const { features } = useFeaturesQuery(); const { features } = useFeaturesQuery();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [confirmCouponOpen, setConfirmCouponOpen] = useState(false);
const reward = useRewardStore((s) => s.reward); const reward = useRewardStore((s) => s.reward);
const setReward = useRewardStore((s) => s.setReward); const setReward = useRewardStore((s) => s.setReward);
@@ -106,8 +115,8 @@ export function UpdateRewardSheet({
return true; return true;
}; };
const handleUpdate = async () => { const performUpdate = async () => {
if (!selectedReward || !isFormValid()) return; if (!selectedReward) return;
setLoading(true); setLoading(true);
try { try {
@@ -124,6 +133,7 @@ export function UpdateRewardSheet({
await refetch(); await refetch();
toast.success("Reward updated successfully"); toast.success("Reward updated successfully");
setConfirmCouponOpen(false);
setOpen(false); setOpen(false);
} catch (error: unknown) { } catch (error: unknown) {
toast.error( toast.error(
@@ -134,11 +144,24 @@ export function UpdateRewardSheet({
} }
}; };
const handleUpdate = async () => {
if (!selectedReward || !isFormValid()) return;
// Stripe can't update coupons in place, so discount updates delete & recreate.
if (reward.rewardCategory === "discount") {
setConfirmCouponOpen(true);
return;
}
await performUpdate();
};
const handleCancel = () => { const handleCancel = () => {
setOpen(false); setOpen(false);
}; };
return ( return (
<>
<Sheet open={open} onOpenChange={setOpen}> <Sheet open={open} onOpenChange={setOpen}>
<SheetContent className="flex flex-col overflow-hidden"> <SheetContent className="flex flex-col overflow-hidden">
<SheetHeader <SheetHeader
@@ -184,5 +207,39 @@ export function UpdateRewardSheet({
</SheetFooter> </SheetFooter>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
<Dialog open={confirmCouponOpen} onOpenChange={setConfirmCouponOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Update coupon?</DialogTitle>
<DialogDescription>
Stripe doesn't have functionality to update coupons. This will
delete it and recreate it. Existing customers that have this
coupon will be unaffected.
</DialogDescription>
</DialogHeader>
<DialogFooter className="grid grid-cols-2 gap-2">
<ShortcutButton
variant="secondary"
className="w-full"
onClick={() => setConfirmCouponOpen(false)}
singleShortcut="escape"
disabled={loading}
>
Cancel
</ShortcutButton>
<ShortcutButton
className="w-full"
onClick={performUpdate}
metaShortcut="enter"
isLoading={loading}
>
Confirm
</ShortcutButton>
</DialogFooter>
</DialogContent>
</Dialog>
</>
); );
} }

View File

@@ -5,25 +5,16 @@ import {
RewardReceivedBy, RewardReceivedBy,
RewardTriggerEvent, RewardTriggerEvent,
} from "@autumn/shared"; } from "@autumn/shared";
import { Check, ChevronsUpDown, X } from "lucide-react"; import { PackageIcon, XIcon } from "@phosphor-icons/react";
import { useId, useState } from "react"; import { useId } from "react";
import FieldLabel from "@/components/general/modal-components/FieldLabel"; import FieldLabel from "@/components/general/modal-components/FieldLabel";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Button } from "@/components/v2/buttons/Button";
import { Checkbox } from "@/components/v2/checkboxes/Checkbox"; import { Checkbox } from "@/components/v2/checkboxes/Checkbox";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/v2/dropdowns/DropdownMenu";
import { Input } from "@/components/v2/inputs/Input"; import { Input } from "@/components/v2/inputs/Input";
import { import {
Select, Select,
@@ -188,6 +179,8 @@ export const RewardProgramConfig = ({
); );
}; };
const MAX_VISIBLE_CHIPS = 3;
const ProductSelector = ({ const ProductSelector = ({
rewardProgram, rewardProgram,
setRewardProgram, setRewardProgram,
@@ -196,89 +189,89 @@ const ProductSelector = ({
setRewardProgram: (rewardProgram: RewardProgram) => void; setRewardProgram: (rewardProgram: RewardProgram) => void;
}) => { }) => {
const { products } = useProductsQuery(); const { products } = useProductsQuery();
const [open, setOpen] = useState(false);
// Handle selection/deselection of a product const productIds = rewardProgram.product_ids ?? [];
const handleProductToggle = (productId: string) => {
let newProductIds = [...(rewardProgram.product_ids || [])]; const toggleProduct = (productId: string) =>
if (newProductIds.includes(productId)) {
newProductIds = newProductIds.filter((id) => id !== productId);
} else {
newProductIds = [...newProductIds, productId];
}
setRewardProgram({ setRewardProgram({
...rewardProgram, ...rewardProgram,
product_ids: newProductIds, product_ids: productIds.includes(productId)
? productIds.filter((id) => id !== productId)
: [...productIds, productId],
}); });
};
if (!products || products.length === 0) { if (!products || products.length === 0) {
return <p className="text-sm text-tertiary-foreground">No products available</p>; return (
<p className="text-sm text-tertiary-foreground">No products available</p>
);
} }
const getProductText = (productId: string) => { const getProductName = (productId: string) =>
const product = products.find((p: ProductV2) => p.id === productId); products.find((p: ProductV2) => p.id === productId)?.name ?? "Unknown plan";
return product?.name || "Unknown Plan";
};
return ( return (
<Popover modal open={open} onOpenChange={setOpen}> <div className="min-w-0 w-full">
<PopoverTrigger asChild> <DropdownMenu>
<Button <DropdownMenuTrigger className="flex h-8 w-full min-w-0 cursor-pointer items-center gap-1.5 overflow-hidden rounded-xl px-3 input-base input-state-open-tiny text-sm">
variant="muted" {productIds.length === 0 ? (
role="combobox" <span className="text-tertiary-foreground">Select plans...</span>
aria-expanded={open} ) : (
className="w-full min-h-9 flex flex-wrap h-fit py-2 justify-start items-center gap-2 relative data-[state=open]:border-focus data-[state=open]:shadow-focus" <>
> {productIds.slice(0, MAX_VISIBLE_CHIPS).map((productId) => (
{rewardProgram.product_ids?.length === 0 <span
? "Select Plans" className="flex h-4.5 max-w-48 shrink-0 items-center gap-0.5 rounded border border-border bg-accent px-1 text-[10px] text-foreground"
: rewardProgram.product_ids?.map((productId: string) => (
<div
key={productId} key={productId}
className="py-0 px-3 text-xs text-tertiary-foreground border-zinc-300 bg-zinc-100 rounded-full w-fit flex items-center gap-2 h-fit"
> >
<p className="text-muted-foreground">{getProductText(productId)}</p> <span className="shrink-0 [&_svg]:size-3">
<Button <PackageIcon
variant="skeleton" className="text-tertiary-foreground"
size="sm" size={12}
weight="duotone"
/>
</span>
<span className="truncate">{getProductName(productId)}</span>
<span
className="ml-0.5 cursor-pointer text-tertiary-foreground hover:text-destructive"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handleProductToggle(productId); toggleProduct(productId);
}} }}
className="bg-transparent hover:bg-transparent p-0 w-5 h-5" onPointerDown={(e) => e.stopPropagation()}
> >
<X size={12} className="text-tertiary-foreground" /> <XIcon size={10} />
</Button> </span>
</div> </span>
))} ))}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50 absolute right-2" /> {productIds.length > MAX_VISIBLE_CHIPS && (
</Button> <span className="shrink-0 px-1 text-sm text-tertiary-foreground">
</PopoverTrigger> +{productIds.length - MAX_VISIBLE_CHIPS}
<PopoverContent className="w-[400px] p-0" align="start"> </span>
<Command>
<CommandInput placeholder="Search plans..." className="h-9" />
<CommandList className="max-h-[300px] overflow-y-auto">
<ScrollArea>
<CommandEmpty>No products found.</CommandEmpty>
<CommandGroup>
{products.map((product: ProductV2) => (
<CommandItem
key={product.id}
value={product.id}
onSelect={() => handleProductToggle(product.id)}
className="cursor-pointer"
>
<div className="flex items-center">{product.name}</div>
{rewardProgram.product_ids?.includes(product.id) && (
<Check size={12} className="text-tertiary-foreground" />
)} )}
</CommandItem> </>
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64">
<div className="max-h-72 overflow-y-auto">
{products.map((product: ProductV2) => (
<DropdownMenuItem
className="flex cursor-pointer items-center gap-2 font-medium"
closeOnClick={false}
key={product.id}
onClick={(e) => {
e.preventDefault();
toggleProduct(product.id);
}}
>
<Checkbox
checked={productIds.includes(product.id)}
className="border-border"
/>
<span className="truncate">{product.name}</span>
</DropdownMenuItem>
))} ))}
</CommandGroup> </div>
</ScrollArea> </DropdownMenuContent>
</CommandList> </DropdownMenu>
</Command> </div>
</PopoverContent>
</Popover>
); );
}; };