From 2de53a5d890eb4e817a0ab01f551930d24a3c1b2 Mon Sep 17 00:00:00 2001 From: johnyeo Date: Fri, 12 Jun 2026 20:08:00 +0100 Subject: [PATCH 1/3] fix: claude oauth --- packages/auth/src/oauth/leafOAuth.ts | 13 +++-- .../auth/oauth/handleOAuthTokenWithApiKey.ts | 12 +++-- .../internal/auth/oauth/oauthConsentScopes.ts | 26 ++++++--- .../unit/auth/registerMcpOAuthClient.test.ts | 54 +++++++++++++++---- 4 files changed, 78 insertions(+), 27 deletions(-) diff --git a/packages/auth/src/oauth/leafOAuth.ts b/packages/auth/src/oauth/leafOAuth.ts index b91ef7de3..0b3d9dda2 100644 --- a/packages/auth/src/oauth/leafOAuth.ts +++ b/packages/auth/src/oauth/leafOAuth.ts @@ -1,15 +1,20 @@ import { LEAF_OAUTH_SCOPES } from "@autumn/shared/leafOAuthScopes"; -import type { ScopeString } from "@autumn/shared/scopeDefinitions"; +import { OPENID_SCOPES } from "@autumn/shared/scopeDefinitions"; const leafScopeSet = new Set(LEAF_OAUTH_SCOPES); +const oauthPassthroughScopeSet = new Set(["offline_access"]); +const oauthProtocolScopeSet = new Set(OPENID_SCOPES); export const getDefaultOAuthScopes = (requestedScopes?: string[] | null) => { const requested = requestedScopes && requestedScopes.length > 0 ? requestedScopes - : [...LEAF_OAUTH_SCOPES]; + : [...LEAF_OAUTH_SCOPES, ...oauthPassthroughScopeSet]; - return [...new Set(requested)].filter((scope): scope is ScopeString => - leafScopeSet.has(scope), + return [...new Set(requested)].filter( + (scope) => leafScopeSet.has(scope) || oauthPassthroughScopeSet.has(scope), ); }; + +export const getOAuthResourceScopes = (scopes: readonly T[]) => + scopes.filter((scope) => !oauthProtocolScopeSet.has(scope)); diff --git a/server/src/internal/auth/oauth/handleOAuthTokenWithApiKey.ts b/server/src/internal/auth/oauth/handleOAuthTokenWithApiKey.ts index 7b933a553..2e4ea2068 100644 --- a/server/src/internal/auth/oauth/handleOAuthTokenWithApiKey.ts +++ b/server/src/internal/auth/oauth/handleOAuthTokenWithApiKey.ts @@ -1,5 +1,6 @@ import { prefixOAuthToken } from "@autumn/auth"; import { + getOAuthResourceScopes, getResourceFromOAuthTokenRequest, returnsOAuthAccessTokenForClientId, } from "@autumn/auth/oauth"; @@ -123,7 +124,10 @@ export const handleOAuthTokenWithApiKey = async (c: Context) => { const accessToken = getString(tokenPayload.access_token); if (!accessToken) return response; - const requestedScopes = scopesFromOAuthScopeString(tokenPayload.scope); + const parsedRequestedScopes = scopesFromOAuthScopeString(tokenPayload.scope); + const requestedScopes = parsedRequestedScopes + ? getOAuthResourceScopes(parsedRequestedScopes) + : null; let apiKeyResult: Awaited>; try { const tokenRecord = await getOAuthAccessTokenRecord({ @@ -142,15 +146,15 @@ export const handleOAuthTokenWithApiKey = async (c: Context) => { const issuedScopes = await getOAuthConsentScopeGrant({ db, organizationId: tokenRecord.referenceId, - requestedScopes: tokenRecord.scopes, + requestedScopes: parsedRequestedScopes ?? tokenRecord.scopes, userId: tokenRecord.userId, }); - tokenRecord.scopes = issuedScopes; + tokenRecord.scopes = getOAuthResourceScopes(issuedScopes); if (tokenRecord.id) { await oauthAccessTokenRepo.updateScopes({ db, id: tokenRecord.id, - scopes: issuedScopes, + scopes: tokenRecord.scopes, }); } if (tokenRecord.refreshId) { diff --git a/server/src/internal/auth/oauth/oauthConsentScopes.ts b/server/src/internal/auth/oauth/oauthConsentScopes.ts index eaa21f06d..cf1392f6e 100644 --- a/server/src/internal/auth/oauth/oauthConsentScopes.ts +++ b/server/src/internal/auth/oauth/oauthConsentScopes.ts @@ -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 type { DrizzleCli } from "@/db/initDrizzle.js"; import { getScopesForUserInOrg } from "@/utils/authUtils/customSessionScopes.js"; @@ -21,14 +24,21 @@ export const getOAuthConsentScopeGrant = async ({ organizationId, }); - const grant = finalRequestedScopes.filter((scope) => + const resourceScopes = getOAuthResourceScopes(finalRequestedScopes); + const resourceGrant = resourceScopes.filter((scope) => isScopeSubset([scope], userScopes), ); - if (grant.length > 0) return grant; + if (resourceGrant.length === 0) { + throw new RecaseError({ + message: "No requested scopes can be granted to this OAuth client", + code: ErrCode.InsufficientScopes, + statusCode: 403, + }); + } - throw new RecaseError({ - message: "No requested scopes can be granted to this OAuth client", - code: ErrCode.InsufficientScopes, - statusCode: 403, - }); + const grantedResourceScopes = new Set(resourceGrant); + return finalRequestedScopes.filter( + (scope) => + !resourceScopes.includes(scope) || grantedResourceScopes.has(scope), + ); }; diff --git a/server/tests/unit/auth/registerMcpOAuthClient.test.ts b/server/tests/unit/auth/registerMcpOAuthClient.test.ts index 4819fd086..d5ca9d064 100644 --- a/server/tests/unit/auth/registerMcpOAuthClient.test.ts +++ b/server/tests/unit/auth/registerMcpOAuthClient.test.ts @@ -1,46 +1,78 @@ 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 { Scopes } from "@autumn/shared/scopeDefinitions"; 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", () => { - test("defaults Slack MCP clients to Leaf OAuth scopes", () => { + test("defaults Slack MCP clients to Leaf scopes plus offline access", () => { expect( 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( 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( getRequestedScopesForMcpClient({ clientType: "dynamic", 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( getRequestedScopesForMcpClient({ 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( getDefaultOAuthScopes([ Scopes.Customers.Read, Scopes.ApiKeys.Write, Scopes.Analytics.Read, + OFFLINE_ACCESS_SCOPE, + ]), + ).toEqual([ + Scopes.Customers.Read, + Scopes.Analytics.Read, + OFFLINE_ACCESS_SCOPE, + ]); + }); + + 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]); }); From 6e09f4dba1e31db180a36e0571368300fb418209 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Sat, 13 Jun 2026 01:06:17 +0100 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=F0=9F=90=9B=20cli=20auth=20for=20th?= =?UTF-8?q?e=20last=20time?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/auth/src/oauth/leafOAuth.ts | 11 ++++++-- .../cli/handlers/handleCreateOAuthApiKeys.ts | 18 +++++++++++-- .../unit/auth/registerMcpOAuthClient.test.ts | 27 +++++++++++++++++++ 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/packages/auth/src/oauth/leafOAuth.ts b/packages/auth/src/oauth/leafOAuth.ts index 0b3d9dda2..1bd92ebfc 100644 --- a/packages/auth/src/oauth/leafOAuth.ts +++ b/packages/auth/src/oauth/leafOAuth.ts @@ -1,5 +1,8 @@ import { LEAF_OAUTH_SCOPES } from "@autumn/shared/leafOAuthScopes"; -import { OPENID_SCOPES } from "@autumn/shared/scopeDefinitions"; +import { + LEGACY_SCOPE_ALIASES, + OPENID_SCOPES, +} from "@autumn/shared/scopeDefinitions"; const leafScopeSet = new Set(LEAF_OAUTH_SCOPES); const oauthPassthroughScopeSet = new Set(["offline_access"]); @@ -11,8 +14,12 @@ export const getDefaultOAuthScopes = (requestedScopes?: string[] | null) => { ? requestedScopes : [...LEAF_OAUTH_SCOPES, ...oauthPassthroughScopeSet]; + // Issued scopes must echo the client's request verbatim (better-auth + // rejects rewrites), so legacy CRUDL aliases are used for filtering only. return [...new Set(requested)].filter( - (scope) => leafScopeSet.has(scope) || oauthPassthroughScopeSet.has(scope), + (scope) => + leafScopeSet.has(LEGACY_SCOPE_ALIASES[scope] ?? scope) || + oauthPassthroughScopeSet.has(scope), ); }; diff --git a/server/src/internal/dev/cli/handlers/handleCreateOAuthApiKeys.ts b/server/src/internal/dev/cli/handlers/handleCreateOAuthApiKeys.ts index 08ab45a13..b6836a175 100644 --- a/server/src/internal/dev/cli/handlers/handleCreateOAuthApiKeys.ts +++ b/server/src/internal/dev/cli/handlers/handleCreateOAuthApiKeys.ts @@ -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 { isMcpOAuthClientId } from "@/internal/auth/oauth/mcpOAuthScopes.js"; import { @@ -84,7 +90,15 @@ export const handleCreateOAuthApiKeys = createRoute({ 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 })) { throw new RecaseError({ message: "MCP OAuth clients must use OAuth access tokens directly", diff --git a/server/tests/unit/auth/registerMcpOAuthClient.test.ts b/server/tests/unit/auth/registerMcpOAuthClient.test.ts index d5ca9d064..b437a53cd 100644 --- a/server/tests/unit/auth/registerMcpOAuthClient.test.ts +++ b/server/tests/unit/auth/registerMcpOAuthClient.test.ts @@ -64,6 +64,33 @@ describe("getRequestedScopesForMcpClient", () => { ]); }); + 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([ From 4a334dc96146132ed27a3b704d42ed0db967c3d4 Mon Sep 17 00:00:00 2001 From: Ayush Rodrigues Date: Sun, 14 Jun 2026 18:21:58 +0100 Subject: [PATCH 3/3] style: refresh reward selectors and add coupon update confirmation Co-authored-by: Cursor --- knip.json | 7 +- .../components/FeatureGrantRewardConfig.tsx | 2 +- .../components/ProductPriceSelector.tsx | 401 +++++++++++------- .../components/UpdateRewardSheet.tsx | 141 ++++-- .../reward-programs/RewardProgramConfig.tsx | 161 ++++--- 5 files changed, 438 insertions(+), 274 deletions(-) diff --git a/knip.json b/knip.json index a6aa1e07c..48587130b 100644 --- a/knip.json +++ b/knip.json @@ -44,7 +44,12 @@ "vite": { "entry": ["tests/**/*.{ts,tsx}", "src/**/*.test.{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": [ "tailwindcss", "tailwind-scrollbar-hide", diff --git a/vite/src/views/products/rewards/reward-config/components/FeatureGrantRewardConfig.tsx b/vite/src/views/products/rewards/reward-config/components/FeatureGrantRewardConfig.tsx index 960033dec..f2c4c26b6 100644 --- a/vite/src/views/products/rewards/reward-config/components/FeatureGrantRewardConfig.tsx +++ b/vite/src/views/products/rewards/reward-config/components/FeatureGrantRewardConfig.tsx @@ -253,7 +253,7 @@ export function FeatureGrantRewardConfig({ {entitlements.map((ent, index) => ( void; } +const priceItemsOf = (product: ProductV2) => + (product.items ?? []).filter( + (item): item is ProductItem & { price_id: string } => + !isFeatureItem(item) && Boolean(item.price_id), + ); + export function ProductPriceSelector({ reward, setReward, @@ -25,175 +41,268 @@ export function ProductPriceSelector({ const { features } = useFeaturesQuery(); const config = reward.discount_config!; + const priceIds = config.price_ids ?? []; + const applyToAll = config.apply_to_all ?? false; - const linkedPriceIds = config.price_ids ?? []; - const { products: linkedProductVersions } = - useProductsByPriceIdsQuery(linkedPriceIds); + // Selected price IDs may belong to historical versions absent from the + // latest-versions list; resolve their owning product for chip labels. + const { products: linkedProductVersions, isLoading: linkedVersionsLoading } = + useProductsByPriceIdsQuery(priceIds); - const setConfig = (key: string, value: any) => { - 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 + const setPriceIds = (nextPriceIds: string[]) => setReward({ ...reward, discount_config: { ...config, apply_to_all: false, - price_ids: newPriceIds, + price_ids: nextPriceIds, }, }); - }; - const handleApplyToAllToggle = () => { - const newApplyToAll = !config.apply_to_all; + const toggleApplyToAll = () => + setReward({ + ...reward, + discount_config: { + ...config, + apply_to_all: !applyToAll, + price_ids: [], + }, + }); - if (newApplyToAll) { - // Enabling "Apply to all" clears price_ids - setReward({ - ...reward, - discount_config: { - ...config, - apply_to_all: true, - price_ids: [], - }, - }); - } else { - // Disabling "Apply to all" just sets it to false - setConfig("apply_to_all", false); - } - }; - - const formatPriceTag = (priceId: string) => { - const product = linkedProductVersions.find((p: any) => - p.items.find((i: any) => i.price_id === priceId), + const togglePrice = (priceId: string) => + setPriceIds( + priceIds.includes(priceId) + ? priceIds.filter((id) => id !== priceId) + : [...priceIds, priceId], ); - const item = product?.items.find((i: any) => i.price_id === priceId); - if (!item || !product) return "Unknown Price"; + 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 availableProducts = products.filter( + (product) => priceItemsOf(product).length > 0, + ); + + // 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 }); return `${product.name} v${product.version} — ${priceText}`; }; - // Get products with non-feature items - const availableProducts = products.filter((product: any) => { - const nonFeatureItems = product.items?.filter( - (item: ProductItem) => !isFeatureItem(item), - ); - return nonFeatureItems && nonFeatureItems.length > 0; - }); + type Chip = { key: string; label: string; onRemove?: () => void }; - if (!products || products.length === 0) { - return

No products available

; - } + // Collapse a product's prices into a single product chip when all are selected. + const buildChips = (): Chip[] => { + if (applyToAll) return [{ key: "__all__", label: "All products" }]; - // Build options list - not used for display, just for reference - const priceOptions = availableProducts.flatMap((product: any) => { - const nonFeatureItems = product.items.filter( - (item: ProductItem) => !isFeatureItem(item), + const chips: Chip[] = []; + const seenProducts = new Set(); + 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; + } + + chips.push({ + key: priceId, + label: chipLabel(priceId), + onRemove: () => togglePrice(priceId), + }); + } + return chips; + }; + + if (!products || products.length === 0) + return ( +

No products available

); - return nonFeatureItems.map((item: any) => ({ - value: item.price_id, - label: formatProductItemText({ item, org, features }), - })); - }); + + const chips = buildChips(); return ( - 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 */} - -
{ - handleApplyToAllToggle(); - setOpen(false); - }} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - handleApplyToAllToggle(); - setOpen(false); - } - }} - > -
- Apply to all products - {config.apply_to_all && ( - - )} -
-
-
+
+ + + {chips.length === 0 ? ( + + Select plans or apply to all... + + ) : ( + <> + {chips.slice(0, MAX_VISIBLE_CHIPS).map((chip) => ( + + + + + {chip.label} + {chip.onRemove && ( + { + e.stopPropagation(); + chip.onRemove?.(); + }} + onPointerDown={(e) => e.stopPropagation()} + > + + + )} + + ))} + {chips.length > MAX_VISIBLE_CHIPS && ( + + +{chips.length - MAX_VISIBLE_CHIPS} + + )} + + )} + + + { + e.preventDefault(); + toggleApplyToAll(); + }} + > + + Apply to all products + + +
+ {availableProducts.map((product) => { + const priceItems = priceItemsOf(product); - {/* Product groups */} - {availableProducts.map((product: any) => { - const nonFeatureItems = product.items.filter( - (item: ProductItem) => !isFeatureItem(item), - ); + if (priceItems.length === 1) { + const priceId = priceItems[0].price_id; + return ( + { + e.preventDefault(); + togglePrice(priceId); + }} + > + + {product.name} + + ); + } - return ( - - {product.name} - {nonFeatureItems.map((item: any) => { - const isSelected = config.price_ids?.includes(item.price_id); - return ( -
handlePriceToggle(item.price_id)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - handlePriceToggle(item.price_id); - } + 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 ( + + { + e.preventDefault(); + toggleProduct(product); + }} + > + + {product.name} + + + { + e.preventDefault(); + toggleProduct(product); }} > -
+ + All prices + + + {priceItems.map((item) => ( + { + e.preventDefault(); + togglePrice(item.price_id); + }} + > + - {formatProductItemText({ - item, - org, - features, - })} + {formatProductItemText({ item, org, features })} - {isSelected && ( - - )} -
-
- ); - })} -
- ); - })} - - )} - /> + + ))} + + + ); + })} +
+
+
+
); } diff --git a/vite/src/views/products/rewards/reward-config/components/UpdateRewardSheet.tsx b/vite/src/views/products/rewards/reward-config/components/UpdateRewardSheet.tsx index dcebf6e10..f76485ca5 100644 --- a/vite/src/views/products/rewards/reward-config/components/UpdateRewardSheet.tsx +++ b/vite/src/views/products/rewards/reward-config/components/UpdateRewardSheet.tsx @@ -3,6 +3,14 @@ import type { AxiosError } from "axios"; import { useEffect, useState } from "react"; import { toast } from "sonner"; import { ShortcutButton } from "@/components/v2/buttons/ShortcutButton"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/v2/dialogs/Dialog"; import { SheetFooter, SheetHeader, @@ -40,6 +48,7 @@ export function UpdateRewardSheet({ const { features } = useFeaturesQuery(); const [loading, setLoading] = useState(false); + const [confirmCouponOpen, setConfirmCouponOpen] = useState(false); const reward = useRewardStore((s) => s.reward); const setReward = useRewardStore((s) => s.setReward); @@ -102,8 +111,8 @@ export function UpdateRewardSheet({ return true; }; - const handleUpdate = async () => { - if (!selectedReward || !isFormValid()) return; + const performUpdate = async () => { + if (!selectedReward) return; setLoading(true); try { @@ -120,6 +129,7 @@ export function UpdateRewardSheet({ await refetch(); toast.success("Reward updated successfully"); + setConfirmCouponOpen(false); setOpen(false); } catch (error: unknown) { toast.error( @@ -130,55 +140,102 @@ 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 = () => { setOpen(false); }; return ( - - - + <> + + + -
- - +
+ + - {reward.rewardCategory === "discount" && ( - - )} + {reward.rewardCategory === "discount" && ( + + )} - {reward.rewardCategory === "free_product" && ( - - )} + {reward.rewardCategory === "free_product" && ( + + )} - {reward.rewardCategory === "feature_grant" && ( - - )} -
+ {reward.rewardCategory === "feature_grant" && ( + + )} +
- - - Cancel - - - Update reward - - -
-
+ + + Cancel + + + Update reward + + +
+
+ + + + + Update coupon? + + Stripe doesn't have functionality to update coupons. This will + delete it and recreate it. Existing customers that have this + coupon will be unaffected. + + + + + setConfirmCouponOpen(false)} + singleShortcut="escape" + disabled={loading} + > + Cancel + + + Confirm + + + + + ); } diff --git a/vite/src/views/products/rewards/reward-programs/RewardProgramConfig.tsx b/vite/src/views/products/rewards/reward-programs/RewardProgramConfig.tsx index 9d9b11731..6dad41841 100644 --- a/vite/src/views/products/rewards/reward-programs/RewardProgramConfig.tsx +++ b/vite/src/views/products/rewards/reward-programs/RewardProgramConfig.tsx @@ -5,25 +5,16 @@ import { RewardReceivedBy, RewardTriggerEvent, } from "@autumn/shared"; -import { Check, ChevronsUpDown, X } from "lucide-react"; -import { useId, useState } from "react"; +import { PackageIcon, XIcon } from "@phosphor-icons/react"; +import { useId } from "react"; 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 { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/v2/dropdowns/DropdownMenu"; import { Input } from "@/components/v2/inputs/Input"; import { Select, @@ -188,6 +179,8 @@ export const RewardProgramConfig = ({ ); }; +const MAX_VISIBLE_CHIPS = 3; + const ProductSelector = ({ rewardProgram, setRewardProgram, @@ -196,89 +189,89 @@ const ProductSelector = ({ setRewardProgram: (rewardProgram: RewardProgram) => void; }) => { const { products } = useProductsQuery(); - const [open, setOpen] = useState(false); - // Handle selection/deselection of a product - const handleProductToggle = (productId: string) => { - let newProductIds = [...(rewardProgram.product_ids || [])]; - if (newProductIds.includes(productId)) { - newProductIds = newProductIds.filter((id) => id !== productId); - } else { - newProductIds = [...newProductIds, productId]; - } + const productIds = rewardProgram.product_ids ?? []; + + const toggleProduct = (productId: string) => setRewardProgram({ ...rewardProgram, - product_ids: newProductIds, + product_ids: productIds.includes(productId) + ? productIds.filter((id) => id !== productId) + : [...productIds, productId], }); - }; if (!products || products.length === 0) { - return

No products available

; + return ( +

No products available

+ ); } - const getProductText = (productId: string) => { - const product = products.find((p: ProductV2) => p.id === productId); - return product?.name || "Unknown Plan"; - }; + const getProductName = (productId: string) => + products.find((p: ProductV2) => p.id === productId)?.name ?? "Unknown plan"; return ( - - - - + + + ))} - - - - - - - - - No products found. - - {products.map((product: ProductV2) => ( - handleProductToggle(product.id)} - className="cursor-pointer" - > -
{product.name}
- {rewardProgram.product_ids?.includes(product.id) && ( - - )} -
- ))} -
-
-
-
-
-
+ {productIds.length > MAX_VISIBLE_CHIPS && ( + + +{productIds.length - MAX_VISIBLE_CHIPS} + + )} + + )} + + +
+ {products.map((product: ProductV2) => ( + { + e.preventDefault(); + toggleProduct(product.id); + }} + > + + {product.name} + + ))} +
+
+ + ); };