diff --git a/.github/workflows/lint-and-build.yaml b/.github/workflows/lint-and-build.yaml index a9d451c1a..3e269f3f2 100644 --- a/.github/workflows/lint-and-build.yaml +++ b/.github/workflows/lint-and-build.yaml @@ -88,7 +88,15 @@ jobs: if [[ -n $(git status --porcelain) ]]; then echo "Error: There are uncommitted changes after build/lint/typecheck." echo "Please commit all changes before pushing." + echo "" + echo "Files with uncommitted changes:" + git status --porcelain + echo "" + echo "Full git status:" git status + echo "" + echo "Diff of changes:" + git diff exit 1 fi @@ -97,6 +105,14 @@ jobs: if [[ -n $(git status --porcelain) ]]; then echo "Error: There are uncommitted changes after build/lint/typecheck." echo "Please commit all changes before pushing." + echo "" + echo "Files with uncommitted changes:" + git status --porcelain + echo "" + echo "Full git status:" git status + echo "" + echo "Diff of changes:" + git diff exit 1 fi diff --git a/AGENTS.md b/AGENTS.md index 902ab8827..f4d526556 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ -# CLAUDE.md +# AGENTS.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +This file provides guidance to coding agents when working with code in this repository. ## Development Commands @@ -76,6 +76,9 @@ To see all development ports, refer to the index.html of `apps/dev-launchpad/pub - When writing tests, prefer .toMatchInlineSnapshot over other selectors, if possible. You can check (and modify) the snapshot-serializer.ts file to see how the snapshots are formatted and how non-deterministic values are handled. - Whenever you learn something new, or at the latest right before you call the `Stop` tool, write whatever you learned into the ./claude/CLAUDE-KNOWLEDGE.md file, in the Q&A format in there. You will later be able to look up knowledge from there (based on the question you asked). - Animations: Keep hover/click transitions snappy and fast. Don't delay the action with a pre-transition (e.g. no fade-in when hovering a button) — it makes the UI feel sluggish. Instead, apply transitions after the action, like a smooth fade-out when the hover ends. +- Whenever you make changes in the dashboard, provide the user with a deep link to the dashboard page that you've just changed. Usually, this takes the form of `http://localhost:01/projects/-selector-/...`, although sometimes it's different. If $NEXT_PUBLIC_STACK_PORT_PREFIX is set to 91, 92, or 93, use `a.localhost`, `b.localhost`, and `c.localhost` for the domains, respectively. +- To update the list of apps available, edit `apps-frontend.tsx` and `apps-config.ts`. When you're tasked to implement a new app or a new page, always check existing apps for inspiration on how you could implement the new app or page. +- NEVER use Next.js dynamic functions if you can avoid them. Instead, prefer using a client component to make sure the page remains static (eg. prefer `usePathname` instead of `await params`). ### Code-related - Use ES6 maps instead of records wherever you can. diff --git a/apps/backend/src/app/api/latest/auth/password/sign-up/route.tsx b/apps/backend/src/app/api/latest/auth/password/sign-up/route.tsx index 4cd77d481..bc9ad092a 100644 --- a/apps/backend/src/app/api/latest/auth/password/sign-up/route.tsx +++ b/apps/backend/src/app/api/latest/auth/password/sign-up/route.tsx @@ -24,7 +24,7 @@ export const POST = createSmartRouteHandler({ body: yupObject({ email: signInEmailSchema.defined(), password: passwordSchema.defined(), - verification_callback_url: emailVerificationCallbackUrlSchema.defined(), + verification_callback_url: emailVerificationCallbackUrlSchema.optional(), }).defined(), }), response: yupObject({ @@ -41,7 +41,7 @@ export const POST = createSmartRouteHandler({ throw new KnownErrors.PasswordAuthenticationNotEnabled(); } - if (!validateRedirectUrl(verificationCallbackUrl, tenancy)) { + if (verificationCallbackUrl && !validateRedirectUrl(verificationCallbackUrl, tenancy)) { throw new KnownErrors.RedirectUrlNotWhitelisted(); } @@ -66,20 +66,22 @@ export const POST = createSmartRouteHandler({ [KnownErrors.UserWithEmailAlreadyExists] ); - runAsynchronouslyAndWaitUntil((async () => { - await contactChannelVerificationCodeHandler.sendCode({ - tenancy, - data: { - user_id: createdUser.id, - }, - method: { - email, - }, - callbackUrl: verificationCallbackUrl, - }, { - user: createdUser, - }); - })()); + if (verificationCallbackUrl) { + runAsynchronouslyAndWaitUntil((async () => { + await contactChannelVerificationCodeHandler.sendCode({ + tenancy, + data: { + user_id: createdUser.id, + }, + method: { + email, + }, + callbackUrl: verificationCallbackUrl, + }, { + user: createdUser, + }); + })()); + } if (createdUser.requires_totp_mfa) { throw await createMfaRequiredError({ diff --git a/apps/backend/src/lib/email-rendering.tsx b/apps/backend/src/lib/email-rendering.tsx index aa8597bbe..f1116eb54 100644 --- a/apps/backend/src/lib/email-rendering.tsx +++ b/apps/backend/src/lib/email-rendering.tsx @@ -1,6 +1,6 @@ import { Freestyle } from '@/lib/freestyle'; import { emptyEmailTheme } from '@stackframe/stack-shared/dist/helpers/emails'; -import { StackAssertionError } from '@stackframe/stack-shared/dist/utils/errors'; +import { captureError, StackAssertionError } from '@stackframe/stack-shared/dist/utils/errors'; import { bundleJavaScript } from '@stackframe/stack-shared/dist/utils/esbuild'; import { get, has } from '@stackframe/stack-shared/dist/utils/objects'; import { Result } from "@stackframe/stack-shared/dist/utils/results"; @@ -120,11 +120,21 @@ export async function renderEmailWithTemplate( "@react-email/components": "0.1.1", "arktype": "2.1.20", }; - const output = await freestyle.executeScript(result.data, { nodeModules }); - if (output.status === "error") { - return Result.error(`${output.error}`); + const executeResult = await freestyle.executeScript(result.data, { nodeModules }); + if (executeResult.status === "error") { + return Result.error(`${executeResult.error}`); } - return Result.ok(output.data.result as { html: string, text: string, subject: string, notificationCategory: string }); + if (!executeResult.data.result) { + const noResultError = new StackAssertionError("No result from Freestyle", { + executeResult, + templateOrDraftComponent, + themeComponent, + options, + }); + captureError("freestyle-no-result", noResultError); + throw noResultError; + } + return Result.ok(executeResult.data.result as { html: string, text: string, subject: string, notificationCategory: string }); } export async function renderEmailsWithTemplateBatched( @@ -205,6 +215,16 @@ export async function renderEmailsWithTemplateBatched( if (executeResult.status === "error") { return Result.error(executeResult.error); } + if (!executeResult.data.result) { + const noResultError = new StackAssertionError("No result from Freestyle", { + executeResult, + templateOrDraftComponent, + themeComponent, + inputs, + }); + captureError("freestyle-no-result", noResultError); + throw noResultError; + } return Result.ok(executeResult.data.result as Array<{ html: string, text: string, subject?: string, notificationCategory?: string }>); } diff --git a/apps/backend/src/lib/payments.tsx b/apps/backend/src/lib/payments.tsx index 25c913dc3..1865db6ba 100644 --- a/apps/backend/src/lib/payments.tsx +++ b/apps/backend/src/lib/payments.tsx @@ -1,7 +1,7 @@ import { PrismaClientTransaction } from "@/prisma-client"; import { PurchaseCreationSource, SubscriptionStatus } from "@prisma/client"; import { KnownErrors } from "@stackframe/stack-shared"; -import type { inlineProductSchema, productSchema } from "@stackframe/stack-shared/dist/schema-fields"; +import type { inlineProductSchema, productSchema, productSchemaWithMetadata } from "@stackframe/stack-shared/dist/schema-fields"; import { SUPPORTED_CURRENCIES } from "@stackframe/stack-shared/dist/utils/currency-constants"; import { FAR_FUTURE_DATE, addInterval, getIntervalsElapsed } from "@stackframe/stack-shared/dist/utils/dates"; import { StackAssertionError, StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; @@ -16,6 +16,7 @@ import { getStripeForAccount } from "./stripe"; const DEFAULT_PRODUCT_START_DATE = new Date("1973-01-01T12:00:00.000Z"); // monday type Product = yup.InferType; +type ProductWithMetadata = yup.InferType; type SelectedPrice = Exclude[string]; export async function ensureProductIdOrInlineProduct( @@ -23,7 +24,7 @@ export async function ensureProductIdOrInlineProduct( accessType: "client" | "server" | "admin", productId: string | undefined, inlineProduct: yup.InferType | undefined -): Promise { +): Promise { if (productId && inlineProduct) { throw new StatusError(400, "Cannot specify both product_id and product_inline!"); } @@ -61,6 +62,9 @@ export async function ensureProductIdOrInlineProduct( freeTrial: value.free_trial, serverOnly: true, }])), + clientMetadata: inlineProduct.client_metadata ?? undefined, + clientReadOnlyMetadata: inlineProduct.client_read_only_metadata ?? undefined, + serverMetadata: inlineProduct.server_metadata ?? undefined, includedItems: typedFromEntries(Object.entries(inlineProduct.included_items).map(([key, value]) => [key, { repeat: value.repeat ?? "never", quantity: value.quantity ?? 0, @@ -420,13 +424,16 @@ export async function ensureCustomerExists(options: { } } -export function productToInlineProduct(product: Product): yup.InferType { +export function productToInlineProduct(product: ProductWithMetadata): yup.InferType { return { display_name: product.displayName ?? "Product", customer_type: product.customerType, stackable: product.stackable === true, server_only: product.serverOnly === true, included_items: product.includedItems, + client_metadata: product.clientMetadata ?? null, + client_read_only_metadata: product.clientReadOnlyMetadata ?? null, + server_metadata: product.serverMetadata ?? null, prices: product.prices === "include-by-default" ? {} : typedFromEntries(typedEntries(product.prices).map(([key, value]) => [key, filterUndefined({ ...typedFromEntries(SUPPORTED_CURRENCIES.map(c => [c.code, getOrUndefined(value, c.code)])), interval: value.interval, @@ -552,7 +559,7 @@ export async function grantProductToCustomer(options: { tenancy: Tenancy, customerType: "user" | "team" | "custom", customerId: string, - product: Product, + product: ProductWithMetadata, quantity: number, productId: string | undefined, priceId: string | undefined, @@ -691,7 +698,7 @@ export async function getOwnedProductsForCustomer(options: { } for (const purchase of oneTimePurchases) { - const product = purchase.product as Product; + const product = purchase.product as ProductWithMetadata; ownedProducts.push({ id: purchase.productId ?? null, type: "one_time", diff --git a/apps/backend/src/lib/projects.tsx b/apps/backend/src/lib/projects.tsx index 646478efa..170d902b9 100644 --- a/apps/backend/src/lib/projects.tsx +++ b/apps/backend/src/lib/projects.tsx @@ -223,12 +223,6 @@ export async function createOrUpdateProjectWithLegacyConfig( 'rbac.defaultPermissions.teamMember': translateDefaultPermissions(dataOptions.team_member_default_permissions), 'rbac.defaultPermissions.teamCreator': translateDefaultPermissions(dataOptions.team_creator_default_permissions), 'rbac.defaultPermissions.signUp': translateDefaultPermissions(dataOptions.user_default_permissions), - // ======================= apps ======================= - 'apps.installed': { - authentication: { enabled: true }, - emails: { enabled: true }, - "launch-checklist": { enabled: true }, - }, }); if (options.type === "create") { @@ -257,6 +251,10 @@ export async function createOrUpdateProjectWithLegacyConfig( configOverrideOverride['rbac.defaultPermissions.teamMember'] ??= { 'team_member': true }; configOverrideOverride['auth.password.allowSignIn'] ??= true; + + configOverrideOverride['apps.installed.authentication.enabled'] ??= true; + configOverrideOverride['apps.installed.emails.enabled'] ??= true; + configOverrideOverride['apps.installed.api-keys.enabled'] ??= true; } await overrideEnvironmentConfigOverride({ projectId: projectId, diff --git a/apps/backend/src/middleware.tsx b/apps/backend/src/middleware.tsx index a7ad77f42..019269924 100644 --- a/apps/backend/src/middleware.tsx +++ b/apps/backend/src/middleware.tsx @@ -9,7 +9,7 @@ import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; import { SmartRouter } from './smart-router'; -const DEV_RATE_LIMIT_MAX_REQUESTS = 30; +const DEV_RATE_LIMIT_MAX_REQUESTS = 100; const DEV_RATE_LIMIT_WINDOW_MS = 10_000; const devRateLimitTimestamps: number[] = []; diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx index d441a3a2f..f4e35d9fc 100644 --- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx @@ -3,14 +3,14 @@ import { ProjectCard } from "@/components/project-card"; import { useRouter } from "@/components/router"; import { SearchBar } from "@/components/search-bar"; -import { AdminOwnedProject, StackAdminApp, Team, useUser } from "@stackframe/stack"; +import { AdminOwnedProject, Team, useUser } from "@stackframe/stack"; import { strictEmailSchema, yupObject } from "@stackframe/stack-shared/dist/schema-fields"; import { groupBy } from "@stackframe/stack-shared/dist/utils/arrays"; import { wait } from "@stackframe/stack-shared/dist/utils/promises"; import { stringCompare } from "@stackframe/stack-shared/dist/utils/strings"; -import { Button, Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, Input, Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue, Skeleton, Spinner, Typography, toast } from "@stackframe/stack-ui"; +import { Button, Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, Input, Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue, Skeleton, Typography, toast } from "@stackframe/stack-ui"; import { Settings } from "lucide-react"; -import { useEffect, useMemo, useState, Suspense } from "react"; +import { Suspense, useEffect, useMemo, useState } from "react"; import * as yup from "yup"; export default function PageClient() { @@ -104,7 +104,6 @@ export default function PageClient() { {team && ( )} @@ -127,7 +126,6 @@ const inviteFormSchema = yupObject({ function TeamAddUserDialog(props: { team: Team, - adminApp: StackAdminApp, }) { const [open, setOpen] = useState(false); @@ -151,7 +149,6 @@ function TeamAddUserDialog(props: { }> setOpen(false)} /> @@ -163,25 +160,31 @@ function TeamAddUserDialog(props: { function TeamAddUserDialogContent(props: { teamId: string, - adminApp: StackAdminApp, onClose: () => void, }) { - const team = props.adminApp.useTeam(props.teamId)!; - const invitations = team.useInvitations(); - const users = team.useUsers(); - const admins = team.useItem("dashboard_admins"); - const [email, setEmail] = useState(""); const [formError, setFormError] = useState(null); - const activeSeats = users.length + invitations.length; + const user = useUser(); + const team = user?.useTeam(props.teamId); + if (!team) { + setTimeout(() => { + props.onClose(); + }); + return null; + } + //const invitations = team.useInvitations(); + const users = team.useUsers(); + const admins = team.useItem("dashboard_admins"); + + //const activeSeats = users.length + invitations.length; const seatLimit = admins.quantity; - const atCapacity = activeSeats >= seatLimit; + //const atCapacity = activeSeats >= seatLimit; const handleInvite = async () => { - if (atCapacity) { - return; - } + //if (atCapacity) { + // return; + //} try { setFormError(null); @@ -215,17 +218,16 @@ function TeamAddUserDialogContent(props: { return ( <>
-
+ {/*
Dashboard admin seats {activeSeats}/{seatLimit} - -
- {atCapacity && ( + */} + {/*{atCapacity && ( You are at capacity. Upgrade your plan to add more admins. - )} + )}*/}
-
+ {/*
Pending invitations {invitations.length === 0 ? ( None @@ -271,22 +273,23 @@ function TeamAddUserDialogContent(props: { ))}
)} -
+
*/}
- {atCapacity ? ( + {/*atCapacity ? ( - ) : ( - - )} + ) : */ + ( + + )} ); diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/api-keys-app/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/api-keys-app/page-client.tsx new file mode 100644 index 000000000..457d9e6f3 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/api-keys-app/page-client.tsx @@ -0,0 +1,52 @@ +"use client"; +import { SettingCard, SettingSwitch } from "@/components/settings"; +import { Typography } from "@stackframe/stack-ui"; +import { AppEnabledGuard } from "../app-enabled-guard"; +import { PageLayout } from "../page-layout"; +import { useAdminApp } from "../use-admin-app"; + +export default function PageClient() { + const stackAdminApp = useAdminApp(); + const project = stackAdminApp.useProject(); + + return ( + + + + { + await project.update({ + config: { + allowUserApiKeys: checked + } + }); + }} + /> + + Enable to allow users to create API keys for their accounts. Enables user-api-keys backend routes. + + + { + await project.update({ + config: { + allowTeamApiKeys: checked + } + }); + }} + /> + + Enable to allow users to create API keys for their teams. Enables team-api-keys backend routes. + + + + + ); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/api-keys-app/page.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/api-keys-app/page.tsx new file mode 100644 index 000000000..f8b59f27c --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/api-keys-app/page.tsx @@ -0,0 +1,11 @@ +import PageClient from "./page-client"; + +export const metadata = { + title: "API Keys", +}; + +export default function Page() { + return ( + + ); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/api-keys/page.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/api-keys/page.tsx index f8b59f27c..96c4f739d 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/api-keys/page.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/api-keys/page.tsx @@ -1,11 +1,11 @@ -import PageClient from "./page-client"; +// This page used to be the location of Project Keys before it was moved to /project-keys +// Redirecting to the new location +import { redirect } from 'next/navigation'; -export const metadata = { - title: "API Keys", -}; - -export default function Page() { - return ( - - ); +export default function Page({ + params, +}: { + params: { projectId: string }, +}) { + redirect(`/projects/${params.projectId}/project-keys`); } diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/api-keys/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/project-keys/page-client.tsx similarity index 82% rename from apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/api-keys/page-client.tsx rename to apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/project-keys/page-client.tsx index 127208378..a0e23bd80 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/api-keys/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/project-keys/page-client.tsx @@ -23,29 +23,27 @@ export default function PageClient() { const [returnedApiKey, setReturnedApiKey] = useState(null); return ( - - setIsNewApiKeyDialogOpen(true)}> - Create Stack Auth Keys - - } - > - + setIsNewApiKeyDialogOpen(true)}> + Create Project Keys + + } + > + - - setReturnedApiKey(null)} - /> + + setReturnedApiKey(null)} + /> - - + ); } @@ -80,7 +78,7 @@ function CreateDialog(props: { return { @@ -110,7 +108,7 @@ function ShowKeyDialog(props: { return (
- Here are your Stack Auth keys.{" "} + Here are your project keys.{" "} Copy them to a safe place. You will not be able to view them again. diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/project-keys/page.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/project-keys/page.tsx new file mode 100644 index 000000000..505202a85 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/project-keys/page.tsx @@ -0,0 +1,11 @@ +import PageClient from "./page-client"; + +export const metadata = { + title: "Project Keys", +}; + +export default function Page() { + return ( + + ); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx index caea6921c..ddba3c141 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx @@ -28,6 +28,7 @@ import { ChevronDown, ChevronRight, Globe, + KeyRound, LucideIcon, Menu, Settings, @@ -79,6 +80,12 @@ const bottomItems: BottomItem[] = [ icon: Blocks, regex: /^\/projects\/[^\/]+\/apps(\/.*)?$/, }, + { + name: 'Project Keys', + href: '/project-keys', + icon: KeyRound, + regex: /^\/projects\/[^\/]+\/project-keys(\/.*)?$/, + }, { name: 'Project Settings', href: '/project-settings', diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/teams/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/teams/page-client.tsx index a0a111d1e..b721a1c63 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/teams/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/teams/page-client.tsx @@ -1,7 +1,8 @@ "use client"; import { TeamTable } from "@/components/data-table/team-table"; import { SmartFormDialog } from "@/components/form-dialog"; -import { Button } from "@stackframe/stack-ui"; +import { StyledLink } from "@/components/link"; +import { Alert, Button } from "@stackframe/stack-ui"; import React from "react"; import * as yup from "yup"; import { AppEnabledGuard } from "../app-enabled-guard"; @@ -16,8 +17,11 @@ type CreateDialogProps = { export default function PageClient() { const stackAdminApp = useAdminApp(); const teams = stackAdminApp.useTeams(); + const project = stackAdminApp.useProject(); const [createTeamsOpen, setCreateTeamsOpen] = React.useState(false); + const hasTeams = teams.length > 0; + const teamSettingsPath = project.ownerTeamId ? `/projects?team_settings=${encodeURIComponent(project.ownerTeamId)}` : null; return ( @@ -28,6 +32,12 @@ export default function PageClient() { Create Team }> + {!hasTeams && teamSettingsPath && ( + + Are you looking to invite a user to your project?{" "} + Go here. + + )} + Use metadata to store a custom JSON object on the user.{" "} + Learn more in the docs. + + } >
{ await user.setClientMetadata(value); @@ -1144,7 +1152,7 @@ function MetadataSection({ user }: MetadataSectionProps) { /> { await user.setClientReadOnlyMetadata(value); @@ -1152,7 +1160,7 @@ function MetadataSection({ user }: MetadataSectionProps) { /> { await user.setServerMetadata(value); diff --git a/apps/dashboard/src/components/form-fields.tsx b/apps/dashboard/src/components/form-fields.tsx index 986bc7937..9e1918d0e 100644 --- a/apps/dashboard/src/components/form-fields.tsx +++ b/apps/dashboard/src/components/form-fields.tsx @@ -1,6 +1,6 @@ "use client"; import { cn } from "@/lib/utils"; -import { Button, Calendar, Checkbox, FormControl, FormField, FormItem, FormLabel, FormMessage, Input, Popover, PopoverContent, PopoverTrigger, Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue, Switch, Textarea } from "@stackframe/stack-ui"; +import { Button, Calendar, Checkbox, FormControl, FormField, FormItem, FormLabel, FormMessage, Input, Popover, PopoverContent, PopoverTrigger, Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue, Switch, Textarea, Typography } from "@stackframe/stack-ui"; import { CalendarIcon } from "lucide-react"; import { Control, FieldValues, Path } from "react-hook-form"; @@ -48,6 +48,11 @@ export function TextAreaField(props: { }} /> + {props.helperText ? ( + + {props.helperText} + + ) : null} diff --git a/apps/dashboard/src/components/settings.tsx b/apps/dashboard/src/components/settings.tsx index ccbaaf261..740cfc689 100644 --- a/apps/dashboard/src/components/settings.tsx +++ b/apps/dashboard/src/components/settings.tsx @@ -10,7 +10,7 @@ import * as yup from "yup"; export function SettingCard(props: { title?: string, - description?: string, + description?: React.ReactNode, actions?: React.ReactNode, children?: React.ReactNode, accordion?: string, diff --git a/apps/dashboard/src/components/user-dialog.tsx b/apps/dashboard/src/components/user-dialog.tsx index 504cbe509..1f1c4ee17 100644 --- a/apps/dashboard/src/components/user-dialog.tsx +++ b/apps/dashboard/src/components/user-dialog.tsx @@ -6,6 +6,9 @@ import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, Button, T import * as yup from "yup"; import { FormDialog } from "./form-dialog"; import { DateField, InputField, SwitchField, TextAreaField } from "./form-fields"; +import { StyledLink } from "./link"; + +const metadataDocsUrl = "https://docs.stack-auth.com/docs/concepts/custom-user-data"; export function UserDialog(props: { open?: boolean, @@ -149,9 +152,48 @@ export function UserDialog(props: { Metadata - - - + + Custom JSON clients can read and update; avoid sensitive data.{" "} + Learn more in the docs. + + } + /> + + Custom JSON clients can read but only your backend can change.{" "} + Learn more in the docs. + + } + /> + + Custom JSON reserved for server-side logic and never exposed to clients.{" "} + Learn more in the docs. + + } + /> diff --git a/apps/dashboard/src/lib/apps-frontend.tsx b/apps/dashboard/src/lib/apps-frontend.tsx index 9277fb302..0ceedf2a2 100644 --- a/apps/dashboard/src/lib/apps-frontend.tsx +++ b/apps/dashboard/src/lib/apps-frontend.tsx @@ -97,7 +97,7 @@ export const ALL_APPS_FRONTEND = { }, "api-keys": { icon: KeyRound, - href: "api-keys", + href: "api-keys-app", navigationItems: [ { displayName: "API Keys", href: "." }, ], diff --git a/apps/e2e/tests/backend/endpoints/api/v1/auth/password/sign-up.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/auth/password/sign-up.test.ts index 2b147c7d3..1c76ef452 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/auth/password/sign-up.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/auth/password/sign-up.test.ts @@ -1,7 +1,7 @@ import { generateSecureRandomString } from "@stackframe/stack-shared/dist/utils/crypto"; import { wait } from "@stackframe/stack-shared/dist/utils/promises"; import { it } from "../../../../../../helpers"; -import { Auth, Project, backendContext, niceBackendFetch } from "../../../../../backend-helpers"; +import { Auth, Project, backendContext, bumpEmailAddress, niceBackendFetch } from "../../../../../backend-helpers"; it("should sign up new users", async ({ expect }) => { const res = await Auth.Password.signUpWithEmail(); @@ -62,6 +62,35 @@ it("should sign up new users", async ({ expect }) => { `); }); +it("should sign up without verification callback and not send email", async ({ expect }) => { + await bumpEmailAddress(); + const mailbox = backendContext.value.mailbox; + const email = mailbox.emailAddress; + const password = generateSecureRandomString(); + + const response = await niceBackendFetch("/api/v1/auth/password/sign-up", { + method: "POST", + accessType: "client", + body: { + email, + password, + }, + }); + + expect(response).toMatchObject({ + status: 200, + body: { + access_token: expect.any(String), + refresh_token: expect.any(String), + user_id: expect.any(String), + }, + }); + + await wait(5000); + const messages = await mailbox.fetchMessages({ noBody: true }); + expect(messages).toMatchInlineSnapshot(`[]`); +}); + it("should not sign up new users if verification callback url is not valid", async ({ expect }) => { const mailbox = backendContext.value.mailbox; const email = mailbox.emailAddress; diff --git a/apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--validate-code.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--validate-code.test.ts index ef63b9980..1e507627a 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--validate-code.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--validate-code.test.ts @@ -41,6 +41,8 @@ it("should allow valid code and return offer data", async ({ expect }) => { "charges_enabled": false, "conflicting_products": [], "product": { + "client_metadata": null, + "client_read_only_metadata": null, "customer_type": "user", "display_name": "Test Product", "included_items": {}, @@ -53,6 +55,7 @@ it("should allow valid code and return offer data", async ({ expect }) => { ], }, }, + "server_metadata": null, "server_only": false, "stackable": false, }, @@ -221,6 +224,8 @@ it("should include conflicting_group_offers when switching within the same group }, ], "product": { + "client_metadata": null, + "client_read_only_metadata": null, "customer_type": "user", "display_name": "Offer B", "included_items": {}, @@ -233,6 +238,7 @@ it("should include conflicting_group_offers when switching within the same group ], }, }, + "server_metadata": null, "server_only": false, "stackable": false, }, diff --git a/apps/e2e/tests/backend/endpoints/api/v1/payments/create-purchase-url.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/payments/create-purchase-url.test.ts index a649b5208..26d329be5 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/payments/create-purchase-url.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/payments/create-purchase-url.test.ts @@ -279,6 +279,89 @@ it("should allow product_inline when calling from server", async ({ expect }) => expect(response.body.url).toMatch(new RegExp(`^https?:\\/\\/localhost:${withPortPrefix("01")}\/purchase\/[a-z0-9-_]+$`)); }); +it("should return inline product metadata when validating purchase code", async ({ expect }) => { + await Project.createAndSwitch({ config: { magic_link_enabled: true } }); + await Payments.setup(); + + const { userId } = await Auth.Otp.signIn(); + const createResponse = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", { + method: "POST", + accessType: "server", + body: { + customer_type: "user", + customer_id: userId, + product_inline: { + display_name: "Metadata Inline Product", + customer_type: "user", + server_only: true, + prices: { + "monthly-metadata": { + USD: "1500", + interval: [1, "month"], + }, + }, + included_items: {}, + server_metadata: { + reference_id: "ref-123", + features: ["priority-support", "analytics"], + }, + }, + }, + }); + expect(createResponse.status).toBe(200); + const url = (createResponse.body as { url: string }).url; + const codeMatch = url.match(/\/purchase\/([a-z0-9-_]+)/); + const fullCode = codeMatch ? codeMatch[1] : undefined; + expect(fullCode).toBeDefined(); + + const validateResponse = await niceBackendFetch("/api/latest/payments/purchases/validate-code", { + method: "POST", + accessType: "client", + body: { + full_code: fullCode, + }, + }); + expect(validateResponse).toMatchInlineSnapshot(` + NiceResponse { + "status": 200, + "body": { + "already_bought_non_stackable": false, + "charges_enabled": false, + "conflicting_products": [], + "product": { + "client_metadata": null, + "client_read_only_metadata": null, + "customer_type": "user", + "display_name": "Metadata Inline Product", + "included_items": {}, + "prices": { + "monthly-metadata": { + "USD": "1500", + "interval": [ + 1, + "month", + ], + }, + }, + "server_metadata": { + "features": [ + "priority-support", + "analytics", + ], + "reference_id": "ref-123", + }, + "server_only": true, + "stackable": false, + }, + "project_id": "", + "stripe_account_id": , + "test_mode": true, + }, + "headers": Headers {