mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
Merge dev into update-oauth-docs
This commit is contained in:
commit
258301a22d
16
.github/workflows/lint-and-build.yaml
vendored
16
.github/workflows/lint-and-build.yaml
vendored
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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 }>);
|
||||
}
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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[] = [];
|
||||
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
import PageClient from "./page-client";
|
||||
|
||||
export const metadata = {
|
||||
title: "API Keys",
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<PageClient />
|
||||
);
|
||||
}
|
||||
@ -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`);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
@ -0,0 +1,11 @@
|
||||
import PageClient from "./page-client";
|
||||
|
||||
export const metadata = {
|
||||
title: "Project Keys",
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<PageClient />
|
||||
);
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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"),
|
||||
});
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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: "." },
|
||||
],
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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", {
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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 }
|
||||
`);
|
||||
});
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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"]>>,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user