Merge dev into update-oauth-docs

This commit is contained in:
Konsti Wohlwend 2025-10-28 04:31:52 -07:00 committed by GitHub
commit 258301a22d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 686 additions and 116 deletions

View File

@ -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

View File

@ -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:<whatever-is-in-$NEXT_PUBLIC_STACK_PORT_PREFIX>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.

View File

@ -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({

View File

@ -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 }>);
}

View File

@ -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<typeof productSchema>;
type ProductWithMetadata = yup.InferType<typeof productSchemaWithMetadata>;
type SelectedPrice = Exclude<Product["prices"], "include-by-default">[string];
export async function ensureProductIdOrInlineProduct(
@ -23,7 +24,7 @@ export async function ensureProductIdOrInlineProduct(
accessType: "client" | "server" | "admin",
productId: string | undefined,
inlineProduct: yup.InferType<typeof inlineProductSchema> | undefined
): Promise<Tenancy["config"]["payments"]["products"][string]> {
): Promise<ProductWithMetadata> {
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<typeof inlineProductSchema> {
export function productToInlineProduct(product: ProductWithMetadata): yup.InferType<typeof inlineProductSchema> {
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",

View File

@ -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,

View File

@ -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[] = [];

View File

@ -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 && (
<TeamAddUserDialog
team={team}
adminApp={projects[0].app}
/>
)}
</div>
@ -127,7 +126,6 @@ const inviteFormSchema = yupObject({
function TeamAddUserDialog(props: {
team: Team,
adminApp: StackAdminApp<false>,
}) {
const [open, setOpen] = useState(false);
@ -151,7 +149,6 @@ function TeamAddUserDialog(props: {
<Suspense fallback={<TeamAddUserDialogContentSkeleton />}>
<TeamAddUserDialogContent
teamId={props.team.id}
adminApp={props.adminApp}
onClose={() => setOpen(false)}
/>
</Suspense>
@ -163,25 +160,31 @@ function TeamAddUserDialog(props: {
function TeamAddUserDialogContent(props: {
teamId: string,
adminApp: StackAdminApp<false>,
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<string | null>(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 (
<>
<div className="space-y-4 py-2">
<div className="flex items-center justify-between rounded-md border border-border px-3 py-2">
{/*<div className="flex items-center justify-between rounded-md border border-border px-3 py-2">
<Typography type="label">Dashboard admin seats</Typography>
<Typography variant="secondary">
{activeSeats}/{seatLimit}
</Typography>
</div>
{atCapacity && (
</Typography>*/}
{/*{atCapacity && (
<Typography variant="secondary" className="text-destructive">
You are at capacity. Upgrade your plan to add more admins.
</Typography>
)}
)}*/}
<div className="space-y-2">
<Input
value={email}
@ -246,7 +248,7 @@ function TeamAddUserDialogContent(props: {
)}
</div>
<div className="space-y-2">
{/*<div className="space-y-2">
<Typography type="label">Pending invitations</Typography>
{invitations.length === 0 ? (
<Typography variant="secondary">None</Typography>
@ -271,22 +273,23 @@ function TeamAddUserDialogContent(props: {
))}
</div>
)}
</div>
</div>*/}
</div>
<DialogFooter className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-end">
<Button variant="outline" onClick={props.onClose}>
Close
</Button>
{atCapacity ? (
{/*atCapacity ? (
<Button onClick={handleUpgrade} variant="default">
Upgrade plan
</Button>
) : (
<Button onClick={handleInvite}>
Invite
</Button>
)}
) : */
(
<Button onClick={handleInvite}>
Invite
</Button>
)}
</DialogFooter>
</>
);

View File

@ -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 (
<AppEnabledGuard appId="api-keys">
<PageLayout title="API Keys" description="Configure API key settings for your project">
<SettingCard
title="API Key Settings"
description="Configure which types of API keys are allowed in your project."
>
<SettingSwitch
label="Allow User API Keys"
checked={project.config.allowUserApiKeys}
onCheckedChange={async (checked) => {
await project.update({
config: {
allowUserApiKeys: checked
}
});
}}
/>
<Typography variant="secondary" type="footnote">
Enable to allow users to create API keys for their accounts. Enables user-api-keys backend routes.
</Typography>
<SettingSwitch
label="Allow Team API Keys"
checked={project.config.allowTeamApiKeys}
onCheckedChange={async (checked) => {
await project.update({
config: {
allowTeamApiKeys: checked
}
});
}}
/>
<Typography variant="secondary" type="footnote">
Enable to allow users to create API keys for their teams. Enables team-api-keys backend routes.
</Typography>
</SettingCard>
</PageLayout>
</AppEnabledGuard>
);
}

View File

@ -0,0 +1,11 @@
import PageClient from "./page-client";
export const metadata = {
title: "API Keys",
};
export default function Page() {
return (
<PageClient />
);
}

View File

@ -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 (
<PageClient />
);
export default function Page({
params,
}: {
params: { projectId: string },
}) {
redirect(`/projects/${params.projectId}/project-keys`);
}

View File

@ -23,29 +23,27 @@ export default function PageClient() {
const [returnedApiKey, setReturnedApiKey] = useState<InternalApiKeyFirstView | null>(null);
return (
<AppEnabledGuard appId="api-keys">
<PageLayout
title="Stack Auth Keys"
actions={
<Button onClick={() => setIsNewApiKeyDialogOpen(true)}>
Create Stack Auth Keys
</Button>
}
>
<InternalApiKeyTable apiKeys={apiKeySets} />
<PageLayout
title="Project Keys"
actions={
<Button onClick={() => setIsNewApiKeyDialogOpen(true)}>
Create Project Keys
</Button>
}
>
<InternalApiKeyTable apiKeys={apiKeySets} />
<CreateDialog
open={isNewApiKeyDialogOpen}
onOpenChange={setIsNewApiKeyDialogOpen}
onKeyCreated={setReturnedApiKey}
/>
<ShowKeyDialog
apiKey={returnedApiKey || undefined}
onClose={() => setReturnedApiKey(null)}
/>
<CreateDialog
open={isNewApiKeyDialogOpen}
onOpenChange={setIsNewApiKeyDialogOpen}
onKeyCreated={setReturnedApiKey}
/>
<ShowKeyDialog
apiKey={returnedApiKey || undefined}
onClose={() => setReturnedApiKey(null)}
/>
</PageLayout>
</AppEnabledGuard>
</PageLayout>
);
}
@ -80,7 +78,7 @@ function CreateDialog(props: {
return <SmartFormDialog
open={props.open}
onOpenChange={props.onOpenChange}
title="Create Stack Auth Keys"
title="Create Project Keys"
formSchema={formSchema}
okButton={{ label: "Create" }}
onSubmit={async (values) => {
@ -110,7 +108,7 @@ function ShowKeyDialog(props: {
return (
<ActionDialog
open={!!props.apiKey}
title="Stack Auth Keys"
title="Project Keys"
okButton={{ label: "Close" }}
onClose={props.onClose}
preventClose
@ -118,7 +116,7 @@ function ShowKeyDialog(props: {
>
<div className="flex flex-col gap-4">
<Typography>
Here are your Stack Auth keys.{" "}
Here are your project keys.{" "}
<span className="font-bold">
Copy them to a safe place. You will not be able to view them again.
</span>

View File

@ -0,0 +1,11 @@
import PageClient from "./page-client";
export const metadata = {
title: "Project Keys",
};
export default function Page() {
return (
<PageClient />
);
}

View File

@ -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',

View File

@ -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 (
<AppEnabledGuard appId="teams">
@ -28,6 +32,12 @@ export default function PageClient() {
Create Team
</Button>
}>
{!hasTeams && teamSettingsPath && (
<Alert className="mb-6">
Are you looking to invite a user to your project?{" "}
<StyledLink href={teamSettingsPath}>Go here</StyledLink>.
</Alert>
)}
<TeamTable teams={teams} />
<CreateDialog
open={createTeamsOpen}
@ -40,8 +50,6 @@ export default function PageClient() {
function CreateDialog({ open, onOpenChange }: CreateDialogProps) {
const stackAdminApp = useAdminApp();
const formSchema = yup.object({
displayName: yup.string().defined().label("Display Name"),
});

View File

@ -3,6 +3,7 @@
import { EditableInput } from "@/components/editable-input";
import { FormDialog, SmartFormDialog } from "@/components/form-dialog";
import { InputField, SelectField } from "@/components/form-fields";
import { StyledLink } from "@/components/link";
import { SettingCard } from "@/components/settings";
import { DeleteUserDialog, ImpersonateUserDialog } from "@/components/user-dialogs";
import { useThemeWatcher } from '@/lib/theme';
@ -47,6 +48,8 @@ import { AppEnabledGuard } from "../../app-enabled-guard";
import { PageLayout } from "../../page-layout";
import { useAdminApp } from "../../use-admin-app";
const metadataDocsUrl = "https://docs.stack-auth.com/docs/concepts/custom-user-data";
type UserInfoProps = {
icon: React.ReactNode,
children: React.ReactNode,
@ -1131,12 +1134,17 @@ function MetadataSection({ user }: MetadataSectionProps) {
return (
<SettingCard
title="Metadata"
description="Use metadata to store a custom JSON object on the user."
description={
<>
Use metadata to store a custom JSON object on the user.{" "}
<StyledLink href={metadataDocsUrl} target="_blank">Learn more in the docs</StyledLink>.
</>
}
>
<div className="grid gap-4 grid-cols-1 lg:grid-cols-3">
<MetadataEditor
title="Client"
hint="Readable and writable from both clients and servers."
hint="Custom JSON clients can read and update; avoid sensitive data."
initialValue={JSON.stringify(user.clientMetadata)}
onUpdate={async (value) => {
await user.setClientMetadata(value);
@ -1144,7 +1152,7 @@ function MetadataSection({ user }: MetadataSectionProps) {
/>
<MetadataEditor
title="Client Read-Only"
hint="Readable from clients, but only writable from servers."
hint="Custom JSON clients can read but only your backend can change."
initialValue={JSON.stringify(user.clientReadOnlyMetadata)}
onUpdate={async (value) => {
await user.setClientReadOnlyMetadata(value);
@ -1152,7 +1160,7 @@ function MetadataSection({ user }: MetadataSectionProps) {
/>
<MetadataEditor
title="Server"
hint="Readable and writable from servers. Not accessible to clients."
hint="Custom JSON reserved for server-side logic and never exposed to clients."
initialValue={JSON.stringify(user.serverMetadata)}
onUpdate={async (value) => {
await user.setServerMetadata(value);

View File

@ -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<F extends FieldValues>(props: {
}}
/>
</FormControl>
{props.helperText ? (
<Typography variant="secondary" className="text-sm leading-snug">
{props.helperText}
</Typography>
) : null}
<FormMessage />
</label>
</FormItem>

View File

@ -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,

View File

@ -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: {
<AccordionItem value="item-1">
<AccordionTrigger>Metadata</AccordionTrigger>
<AccordionContent className="space-y-4">
<TextAreaField rows={3} control={form.control} label="Client metadata" name="clientMetadata" placeholder="null" monospace />
<TextAreaField rows={3} control={form.control} label="Client read only metadata" name="clientReadOnlyMetadata" placeholder="null" monospace />
<TextAreaField rows={3} control={form.control} label="Server metadata" name="serverMetadata" placeholder="null" monospace />
<TextAreaField
rows={3}
control={form.control}
label="Client metadata"
name="clientMetadata"
placeholder="null"
monospace
helperText={
<>
Custom JSON clients can read and update; avoid sensitive data.{" "}
<StyledLink href={metadataDocsUrl} target="_blank">Learn more in the docs</StyledLink>.
</>
}
/>
<TextAreaField
rows={3}
control={form.control}
label="Client read only metadata"
name="clientReadOnlyMetadata"
placeholder="null"
monospace
helperText={
<>
Custom JSON clients can read but only your backend can change.{" "}
<StyledLink href={metadataDocsUrl} target="_blank">Learn more in the docs</StyledLink>.
</>
}
/>
<TextAreaField
rows={3}
control={form.control}
label="Server metadata"
name="serverMetadata"
placeholder="null"
monospace
helperText={
<>
Custom JSON reserved for server-side logic and never exposed to clients.{" "}
<StyledLink href={metadataDocsUrl} target="_blank">Learn more in the docs</StyledLink>.
</>
}
/>
</AccordionContent>
</AccordionItem>
</Accordion>

View File

@ -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: "." },
],

View File

@ -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;

View File

@ -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,
},

View File

@ -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": "<stripped UUID>",
"stripe_account_id": <stripped field 'stripe_account_id'>,
"test_mode": true,
},
"headers": Headers { <some fields may have been hidden> },
}
`);
});
it("should allow valid product_id", async ({ expect }) => {
await Project.createAndSwitch({ config: { magic_link_enabled: true } });
await Payments.setup();

View File

@ -108,6 +108,8 @@ it("should grant configured subscription product and expose it via listing", asy
{
"id": "pro-plan",
"product": {
"client_metadata": null,
"client_read_only_metadata": null,
"customer_type": "user",
"display_name": "Pro Plan",
"included_items": {},
@ -120,6 +122,7 @@ it("should grant configured subscription product and expose it via listing", asy
],
},
},
"server_metadata": null,
"server_only": false,
"stackable": false,
},
@ -200,6 +203,8 @@ it("should hide server-only products from clients while exposing them to servers
{
"id": "server-plan",
"product": {
"client_metadata": null,
"client_read_only_metadata": null,
"customer_type": "user",
"display_name": "Server Plan",
"included_items": {},
@ -212,6 +217,7 @@ it("should hide server-only products from clients while exposing them to servers
],
},
},
"server_metadata": null,
"server_only": true,
"stackable": false,
},
@ -327,6 +333,8 @@ it("should allow granting stackable product with custom quantity", async ({ expe
{
"id": "stackable-plan",
"product": {
"client_metadata": null,
"client_read_only_metadata": null,
"customer_type": "user",
"display_name": "Stackable Plan",
"included_items": {},
@ -339,6 +347,7 @@ it("should allow granting stackable product with custom quantity", async ({ expe
],
},
},
"server_metadata": null,
"server_only": false,
"stackable": true,
},
@ -372,6 +381,10 @@ it("should grant inline product without needing configuration", async ({ expect
},
},
included_items: {},
server_metadata: {
cohort: "beta",
flags: ["inline-grant"],
},
},
},
});
@ -389,6 +402,8 @@ it("should grant inline product without needing configuration", async ({ expect
{
"id": null,
"product": {
"client_metadata": null,
"client_read_only_metadata": null,
"customer_type": "user",
"display_name": "Inline Access",
"included_items": {},
@ -401,6 +416,10 @@ it("should grant inline product without needing configuration", async ({ expect
],
},
},
"server_metadata": {
"cohort": "beta",
"flags": ["inline-grant"],
},
"server_only": true,
"stackable": false,
},
@ -685,6 +704,8 @@ it("listing products should list both subscription and one-time products", async
{
"id": "subscription-plan",
"product": {
"client_metadata": null,
"client_read_only_metadata": null,
"customer_type": "user",
"display_name": "Subscription Plan",
"included_items": {},
@ -697,6 +718,7 @@ it("listing products should list both subscription and one-time products", async
],
},
},
"server_metadata": null,
"server_only": false,
"stackable": false,
},
@ -705,10 +727,13 @@ it("listing products should list both subscription and one-time products", async
{
"id": "lifetime-addon",
"product": {
"client_metadata": null,
"client_read_only_metadata": null,
"customer_type": "user",
"display_name": "Lifetime Add-on",
"included_items": {},
"prices": { "lifetime": { "USD": "5000" } },
"server_metadata": null,
"server_only": false,
"stackable": false,
},
@ -813,6 +838,8 @@ it("listing products should support cursor pagination", async ({ expect }) => {
{
"id": "subscription-plan",
"product": {
"client_metadata": null,
"client_read_only_metadata": null,
"customer_type": "user",
"display_name": "Subscription Plan",
"included_items": {},
@ -825,6 +852,7 @@ it("listing products should support cursor pagination", async ({ expect }) => {
],
},
},
"server_metadata": null,
"server_only": false,
"stackable": false,
},
@ -850,10 +878,13 @@ it("listing products should support cursor pagination", async ({ expect }) => {
{
"id": "lifetime-addon",
"product": {
"client_metadata": null,
"client_read_only_metadata": null,
"customer_type": "user",
"display_name": "Lifetime Add-on",
"included_items": {},
"prices": { "lifetime": { "USD": "5000" } },
"server_metadata": null,
"server_only": false,
"stackable": false,
},
@ -862,10 +893,13 @@ it("listing products should support cursor pagination", async ({ expect }) => {
{
"id": "pro-addon",
"product": {
"client_metadata": null,
"client_read_only_metadata": null,
"customer_type": "user",
"display_name": "Pro Add-on",
"included_items": {},
"prices": { "standard": { "USD": "7000" } },
"server_metadata": null,
"server_only": false,
"stackable": false,
},

View File

@ -437,6 +437,79 @@ it("creates subscription in test mode and increases included item quantity", asy
expect(getAfter.body.quantity).toBe(2);
});
it("should list inline product metadata after completing test-mode purchase", async ({ expect }) => {
await Project.createAndSwitch({ config: { magic_link_enabled: true } });
await Payments.setup();
await Project.updateConfig({
payments: {
testMode: true,
},
});
const { userId } = await Auth.Otp.signIn();
const createPurchaseResponse = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", {
method: "POST",
accessType: "server",
body: {
customer_type: "user",
customer_id: userId,
product_inline: {
display_name: "Inline Metadata Product",
customer_type: "user",
server_only: true,
prices: {
"monthly-inline": {
USD: "1800",
interval: [1, "month"],
},
},
included_items: {},
server_metadata: {
correlation_id: "inline-test-123",
attributes: {
seats: 5,
tier: "gold",
},
},
},
},
});
expect(createPurchaseResponse.status).toBe(200);
const url = (createPurchaseResponse.body as { url: string }).url;
const codeMatch = url.match(/\/purchase\/([a-z0-9-_]+)/);
const code = codeMatch ? codeMatch[1] : undefined;
expect(code).toBeDefined();
const testModePurchaseResponse = await niceBackendFetch("/api/latest/internal/payments/test-mode-purchase-session", {
method: "POST",
accessType: "admin",
body: {
full_code: code,
price_id: "monthly-inline",
},
});
expect(testModePurchaseResponse.status).toBe(200);
expect(testModePurchaseResponse.body).toEqual({ success: true });
const listResponse = await niceBackendFetch(`/api/v1/payments/products/user/${userId}`, {
accessType: "server",
});
expect(listResponse.status).toBe(200);
const listBody = listResponse.body as {
items: Array<{ product: { server_metadata?: Record<string, unknown> } }>,
};
expect(listBody.items).toHaveLength(1);
expect(listBody.items[0].product.server_metadata).toMatchInlineSnapshot(`
{
"attributes": {
"seats": 5,
"tier": "gold",
},
"correlation_id": "inline-test-123",
}
`);
});
it("test-mode should error on invalid code", async ({ expect }) => {
await Project.createAndSwitch();
const response = await niceBackendFetch("/api/latest/internal/payments/test-mode-purchase-session", {

View File

@ -41,6 +41,8 @@ it("should allow valid code and return product 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 product data", async ({ expect }) => {
],
},
},
"server_metadata": null,
"server_only": false,
"stackable": false,
},
@ -221,6 +224,8 @@ it("should include conflicting_products when switching within the same group", a
},
],
"product": {
"client_metadata": null,
"client_read_only_metadata": null,
"customer_type": "user",
"display_name": "Product B",
"included_items": {},
@ -233,6 +238,7 @@ it("should include conflicting_products when switching within the same group", a
],
},
},
"server_metadata": null,
"server_only": false,
"stackable": false,
},
@ -313,6 +319,8 @@ it("should reject untrusted return_url and accept trusted return_url", async ({
"charges_enabled": false,
"conflicting_products": [],
"product": {
"client_metadata": null,
"client_read_only_metadata": null,
"customer_type": "user",
"display_name": "Test Product",
"included_items": {},
@ -325,6 +333,7 @@ it("should reject untrusted return_url and accept trusted return_url", async ({
],
},
},
"server_metadata": null,
"server_only": false,
"stackable": false,
},

View File

@ -1532,3 +1532,64 @@ it("should increment and decrement userCount when a user is added to a project",
expect(finalProjectResponse.body.total_users).toBe(0);
});
it("should preserve API Keys app enabled state when updating allowUserApiKeys config", async ({ expect }) => {
await Auth.Otp.signIn();
const { adminAccessToken } = await Project.createAndGetAdminToken();
// Enable the API Keys app
const enableAppResponse = await niceBackendFetch("/api/v1/internal/config/override", {
accessType: "admin",
method: "PATCH",
headers: {
'x-stack-admin-access-token': adminAccessToken,
},
body: {
config_override_string: JSON.stringify({
'apps.installed.api-keys': {
enabled: true,
},
}),
},
});
expect(enableAppResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": {},
"headers": Headers { <some fields may have been hidden> },
}
`);
// Verify the API Keys app is enabled
const getConfigResponse1 = await niceBackendFetch("/api/v1/internal/config", {
accessType: "admin",
headers: {
'x-stack-admin-access-token': adminAccessToken,
},
});
expect(getConfigResponse1.status).toBe(200);
expect(JSON.parse(getConfigResponse1.body.config_string).apps.installed["api-keys"]).toMatchInlineSnapshot(`
{ "enabled": true }
`);
// Update allowUserApiKeys using the old project update endpoint
const { updateProjectResponse } = await Project.updateCurrent(adminAccessToken, {
config: {
allow_user_api_keys: true,
},
});
expect(updateProjectResponse.status).toBe(200);
expect(updateProjectResponse.body.config.allow_user_api_keys).toBe(true);
// Verify the API Keys app is still enabled after the update
const getConfigResponse2 = await niceBackendFetch("/api/v1/internal/config", {
accessType: "admin",
headers: {
'x-stack-admin-access-token': adminAccessToken,
},
});
expect(getConfigResponse2.status).toBe(200);
expect(JSON.parse(getConfigResponse2.body.config_string).apps.installed["api-keys"]).toMatchInlineSnapshot(`
{ "enabled": true }
`);
});

View File

@ -35,6 +35,49 @@ it("should sign up with credential", async ({ expect }) => {
`);
});
it("should sign up without a verification callback when disabled", async ({ expect }) => {
const { clientApp } = await createApp();
const signUpResult = await clientApp.signUpWithCredential({
email: "no-verification@test.com",
password: "password",
noVerificationCallback: true,
});
expect(signUpResult).toMatchInlineSnapshot(`
{
"data": undefined,
"status": "ok",
}
`);
const signInResult = await clientApp.signInWithCredential({
email: "no-verification@test.com",
password: "password",
});
expect(signInResult).toMatchInlineSnapshot(`
{
"data": undefined,
"status": "ok",
}
`);
});
it("should throw when disabling verification with a callback url provided", async ({ expect }) => {
const { clientApp } = await createApp();
await expect(clientApp.signUpWithCredential({
email: "no-verification-conflict@test.com",
password: "password",
noVerificationCallback: true,
// @ts-expect-error - testing the error case
verificationCallbackUrl: "http://localhost:3000",
})).rejects.toMatchObject({
message: expect.stringContaining("verificationCallbackUrl is not allowed when noVerificationCallback is true"),
name: "StackAssertionError",
});
});
it("should create user on the server", async ({ expect }) => {
const { serverApp } = await createApp();
const user = await serverApp.createUser({

View File

@ -2,3 +2,6 @@
Q: How are the development ports derived now that NEXT_PUBLIC_STACK_PORT_PREFIX exists?
A: Host ports use `${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}` plus the two-digit suffix (e.g., Postgres is `${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}28`, Inbucket SMTP `${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}29`, POP3 `${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}30`, and OTLP `${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}31` by default).
Q: How can I show helper text beneath metadata text areas in the dashboard?
A: Use the shared `TextAreaField` component's `helperText` prop in `apps/dashboard/src/components/form-fields.tsx`; it now renders the helper content in a secondary Typography line under the textarea.

View File

@ -807,7 +807,7 @@ export class StackClientInterface {
async signUpWithCredential(
email: string,
password: string,
emailVerificationRedirectUrl: string,
emailVerificationRedirectUrl: string | undefined,
session: InternalSession,
): Promise<Result<{ accessToken: string, refreshToken: string }, KnownErrors["UserWithEmailAlreadyExists"] | KnownErrors["PasswordRequirementsNotMet"]>> {
const res = await this.sendClientRequestAndCatchKnownError(

View File

@ -590,6 +590,19 @@ export const productSchema = yupObject({
}),
),
});
const productMetadataExample = { featureFlag: true, source: 'marketing-campaign' } as const;
export const productClientMetadataSchema = jsonSchema.meta({ openapiField: { description: _clientMetaDataDescription('product'), exampleValue: productMetadataExample } });
export const productClientReadOnlyMetadataSchema = jsonSchema.meta({ openapiField: { description: _clientReadOnlyMetaDataDescription('product'), exampleValue: productMetadataExample } });
export const productServerMetadataSchema = jsonSchema.meta({ openapiField: { description: _serverMetaDataDescription('product'), exampleValue: productMetadataExample } });
export const productSchemaWithMetadata = productSchema.concat(yupObject({
clientMetadata: productClientMetadataSchema.optional(),
clientReadOnlyMetadata: productClientReadOnlyMetadataSchema.optional(),
serverMetadata: productServerMetadataSchema.optional(),
}));
export const inlineProductSchema = yupObject({
display_name: yupString().defined(),
customer_type: customerTypeSchema.defined(),
@ -612,6 +625,9 @@ export const inlineProductSchema = yupObject({
expires: yupString().oneOf(['never', 'when-purchase-expires', 'when-repeated']).optional(),
}),
),
client_metadata: productClientMetadataSchema.optional(),
client_read_only_metadata: productClientReadOnlyMetadataSchema.optional(),
server_metadata: productServerMetadataSchema.optional(),
});
// Users

View File

@ -1853,17 +1853,39 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
email: string,
password: string,
noRedirect?: boolean,
noVerificationCallback?: boolean,
verificationCallbackUrl?: string,
}): Promise<Result<undefined, KnownErrors["UserWithEmailAlreadyExists"] | KnownErrors['PasswordRequirementsNotMet']>> {
if (options.noVerificationCallback && options.verificationCallbackUrl) {
throw new StackAssertionError("verificationCallbackUrl is not allowed when noVerificationCallback is true");
}
this._ensurePersistentTokenStore();
const session = await this._getSession();
const emailVerificationRedirectUrl = options.verificationCallbackUrl ?? constructRedirectUrl(this.urls.emailVerification, "verificationCallbackUrl");
const result = await this._interface.signUpWithCredential(
const emailVerificationRedirectUrl = options.noVerificationCallback ? undefined : options.verificationCallbackUrl ?? constructRedirectUrl(this.urls.emailVerification, "verificationCallbackUrl");
let result = await this._interface.signUpWithCredential(
options.email,
options.password,
emailVerificationRedirectUrl,
session
);
// If the redirect URL is not whitelisted and we didn't explicitly opt out of verification,
// retry with undefined (no email verification) and log a warning
if (result.status === 'error' &&
result.error instanceof KnownErrors.RedirectUrlNotWhitelisted &&
!options.noVerificationCallback &&
emailVerificationRedirectUrl !== undefined) {
console.error("Warning: The verification callback URL is not trusted. Proceeding with signup without email verification. Please add your domain to the trusted domains list in your Stack Auth dashboard.", { url: emailVerificationRedirectUrl });
result = await this._interface.signUpWithCredential(
options.email,
options.password,
undefined, // No email verification
session
);
}
if (result.status === 'ok') {
await this._signInToAccountWithTokens(result.data);
if (!options.noRedirect) {

View File

@ -44,7 +44,7 @@ export type StackClientApp<HasTokenStore extends boolean = boolean, ProjectId ex
signInWithOAuth(provider: string, options?: { returnTo?: string }): Promise<void>,
signInWithCredential(options: { email: string, password: string, noRedirect?: boolean }): Promise<Result<undefined, KnownErrors["EmailPasswordMismatch"] | KnownErrors["InvalidTotpCode"]>>,
signUpWithCredential(options: { email: string, password: string, noRedirect?: boolean, verificationCallbackUrl?: string }): Promise<Result<undefined, KnownErrors["UserWithEmailAlreadyExists"] | KnownErrors["PasswordRequirementsNotMet"]>>,
signUpWithCredential(options: { email: string, password: string, noRedirect?: boolean } & ({ noVerificationCallback: true } | { noVerificationCallback?: false, verificationCallbackUrl?: string })): Promise<Result<undefined, KnownErrors["UserWithEmailAlreadyExists"] | KnownErrors["PasswordRequirementsNotMet"]>>,
signInWithPasskey(): Promise<Result<undefined, KnownErrors["PasskeyAuthenticationFailed"] | KnownErrors["InvalidTotpCode"] | KnownErrors["PasskeyWebAuthnError"]>>,
callOAuthCallback(): Promise<boolean>,
promptCliLogin(options: { appUrl: string, expiresInMillis?: number }): Promise<Result<string, KnownErrors["CliAuthError"] | KnownErrors["CliAuthExpiredError"] | KnownErrors["CliAuthUsedError"]>>,