diff --git a/apps/backend/prisma/migrations/20260415000000_add_welcome_onboarding_status/migration.sql b/apps/backend/prisma/migrations/20260415000000_add_welcome_onboarding_status/migration.sql new file mode 100644 index 0000000000..03e62a592a --- /dev/null +++ b/apps/backend/prisma/migrations/20260415000000_add_welcome_onboarding_status/migration.sql @@ -0,0 +1,18 @@ +-- Add 'welcome' to the allowed onboarding status values. +-- Drop the old constraint and add a new one (NOT VALID for speed), +-- then validate in the next migration. +ALTER TABLE "Project" + DROP CONSTRAINT "Project_onboardingStatus_valid", + ADD CONSTRAINT "Project_onboardingStatus_valid" + CHECK ( + "onboardingStatus" IN ( + 'config_choice', + 'apps_selection', + 'auth_setup', + 'domain_setup', + 'email_theme_setup', + 'payments_setup', + 'welcome', + 'completed' + ) + ) NOT VALID; diff --git a/apps/backend/prisma/migrations/20260415000000_add_welcome_onboarding_status/tests/welcome-status-allowed.ts b/apps/backend/prisma/migrations/20260415000000_add_welcome_onboarding_status/tests/welcome-status-allowed.ts new file mode 100644 index 0000000000..8ea986447d --- /dev/null +++ b/apps/backend/prisma/migrations/20260415000000_add_welcome_onboarding_status/tests/welcome-status-allowed.ts @@ -0,0 +1,70 @@ +import { randomUUID } from "crypto"; +import type { Sql } from "postgres"; +import { expect } from "vitest"; + +export const preMigration = async (sql: Sql) => { + const projectId = `test-${randomUUID()}`; + await sql` + INSERT INTO "Project" ( + "id", "createdAt", "updatedAt", "displayName", "description", + "isProductionMode", "onboardingStatus" + ) + VALUES (${projectId}, NOW(), NOW(), 'Welcome Test', '', false, 'completed') + `; + + await expect(sql` + UPDATE "Project" + SET "onboardingStatus" = 'welcome' + WHERE "id" = ${projectId} + `).rejects.toThrow(/Project_onboardingStatus_valid/); + + return { projectId }; +}; + +export const postMigration = async (sql: Sql, ctx: Awaited>) => { + await sql` + UPDATE "Project" + SET "onboardingStatus" = 'welcome' + WHERE "id" = ${ctx.projectId} + `; + + const rows = await sql` + SELECT "onboardingStatus" + FROM "Project" + WHERE "id" = ${ctx.projectId} + `; + expect(rows).toHaveLength(1); + expect(rows[0].onboardingStatus).toBe("welcome"); + + const insertWelcomeProjectId = `test-${randomUUID()}`; + await sql` + INSERT INTO "Project" ( + "id", "createdAt", "updatedAt", "displayName", "description", + "isProductionMode", "onboardingStatus" + ) + VALUES (${insertWelcomeProjectId}, NOW(), NOW(), 'Welcome Insert Test', '', false, 'welcome') + `; + + const insertedRows = await sql` + SELECT "onboardingStatus" + FROM "Project" + WHERE "id" = ${insertWelcomeProjectId} + `; + expect(insertedRows).toHaveLength(1); + expect(insertedRows[0].onboardingStatus).toBe("welcome"); + + const insertInvalidProjectId = `test-${randomUUID()}`; + await expect(sql` + INSERT INTO "Project" ( + "id", "createdAt", "updatedAt", "displayName", "description", + "isProductionMode", "onboardingStatus" + ) + VALUES (${insertInvalidProjectId}, NOW(), NOW(), 'Invalid Status Insert', '', false, 'invalid_status') + `).rejects.toThrow(/Project_onboardingStatus_valid/); + + await expect(sql` + UPDATE "Project" + SET "onboardingStatus" = 'invalid_status' + WHERE "id" = ${ctx.projectId} + `).rejects.toThrow(/Project_onboardingStatus_valid/); +}; diff --git a/apps/backend/prisma/migrations/20260415000001_validate_welcome_onboarding_status/migration.sql b/apps/backend/prisma/migrations/20260415000001_validate_welcome_onboarding_status/migration.sql new file mode 100644 index 0000000000..4454dcd782 --- /dev/null +++ b/apps/backend/prisma/migrations/20260415000001_validate_welcome_onboarding_status/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "Project" +VALIDATE CONSTRAINT "Project_onboardingStatus_valid"; diff --git a/apps/backend/prisma/migrations/20260415000001_validate_welcome_onboarding_status/tests/constraint-is-validated.ts b/apps/backend/prisma/migrations/20260415000001_validate_welcome_onboarding_status/tests/constraint-is-validated.ts new file mode 100644 index 0000000000..980aae1ed5 --- /dev/null +++ b/apps/backend/prisma/migrations/20260415000001_validate_welcome_onboarding_status/tests/constraint-is-validated.ts @@ -0,0 +1,12 @@ +import type { Sql } from "postgres"; +import { expect } from "vitest"; + +export const postMigration = async (sql: Sql) => { + const rows = await sql` + SELECT convalidated + FROM pg_constraint + WHERE conname = 'Project_onboardingStatus_valid' + `; + expect(rows).toHaveLength(1); + expect(rows[0].convalidated).toBe(true); +}; diff --git a/apps/backend/src/app/api/latest/internal/config/override/[level]/route.tsx b/apps/backend/src/app/api/latest/internal/config/override/[level]/route.tsx index e5aeb82f21..85de6a3cce 100644 --- a/apps/backend/src/app/api/latest/internal/config/override/[level]/route.tsx +++ b/apps/backend/src/app/api/latest/internal/config/override/[level]/route.tsx @@ -17,13 +17,19 @@ import { LOCAL_EMULATOR_ENV_CONFIG_BLOCKED_MESSAGE, isLocalEmulatorProject } fro import { globalPrismaClient, rawQuery } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { branchConfigSchema, environmentConfigSchema, getConfigOverrideErrors, migrateConfigOverride, projectConfigSchema } from "@stackframe/stack-shared/dist/config/schema"; -import { adaptSchema, adminAuthTypeSchema, branchConfigSourceSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { adaptSchema, branchConfigSourceSchema, serverOrHigherAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { StackAssertionError, StatusError, captureError } from "@stackframe/stack-shared/dist/utils/errors"; import * as yup from "yup"; type BranchConfigSourceApi = yup.InferType; const levelSchema = yupString().oneOf(["project", "branch", "environment"]).defined(); +function assertServerAccessAllowed(accessType: "server" | "admin", level: yup.InferType) { + if (accessType === "server" && level !== "branch") { + throw new StatusError(StatusError.Forbidden, "Server access can only manage branch config overrides."); + } +} + function shouldEnqueueExternalDbSync(config: unknown): boolean { if (!config || typeof config !== "object") return false; const configRecord = config as Record; @@ -124,7 +130,7 @@ export const GET = createSmartRouteHandler({ }, request: yupObject({ auth: yupObject({ - type: adminAuthTypeSchema, + type: serverOrHigherAuthTypeSchema, tenancy: adaptSchema, }).defined(), params: yupObject({ @@ -139,6 +145,8 @@ export const GET = createSmartRouteHandler({ }).defined(), }), handler: async (req) => { + assertServerAccessAllowed(req.auth.type, req.params.level); + const levelConfig = levelConfigs[req.params.level]; const config = await levelConfig.get({ projectId: req.auth.tenancy.project.id, @@ -207,7 +215,7 @@ export const PUT = createSmartRouteHandler({ }, request: yupObject({ auth: yupObject({ - type: adminAuthTypeSchema, + type: serverOrHigherAuthTypeSchema, tenancy: adaptSchema, }).defined(), params: yupObject({ @@ -221,6 +229,8 @@ export const PUT = createSmartRouteHandler({ }), response: writeResponseSchema, handler: async (req) => { + assertServerAccessAllowed(req.auth.type, req.params.level); + if (req.params.level === "environment" && await isLocalEmulatorProject(req.auth.tenancy.project.id)) { throw new StatusError(StatusError.BadRequest, LOCAL_EMULATOR_ENV_CONFIG_BLOCKED_MESSAGE); } @@ -266,7 +276,7 @@ export const PATCH = createSmartRouteHandler({ }, request: yupObject({ auth: yupObject({ - type: adminAuthTypeSchema, + type: serverOrHigherAuthTypeSchema, tenancy: adaptSchema, }).defined(), params: yupObject({ @@ -278,6 +288,8 @@ export const PATCH = createSmartRouteHandler({ }), response: writeResponseSchema, handler: async (req) => { + assertServerAccessAllowed(req.auth.type, req.params.level); + if (req.params.level === "environment" && await isLocalEmulatorProject(req.auth.tenancy.project.id)) { throw new StatusError(StatusError.BadRequest, LOCAL_EMULATOR_ENV_CONFIG_BLOCKED_MESSAGE); } diff --git a/apps/backend/src/app/api/latest/internal/payments/setup/route.ts b/apps/backend/src/app/api/latest/internal/payments/setup/route.ts index 41938830f2..7d0159faa5 100644 --- a/apps/backend/src/app/api/latest/internal/payments/setup/route.ts +++ b/apps/backend/src/app/api/latest/internal/payments/setup/route.ts @@ -24,14 +24,21 @@ export const POST = createSmartRouteHandler({ }), handler: async ({ auth }) => { const stripe = getStackStripe(); + const dashboardBaseUrl = getEnvVariable("NEXT_PUBLIC_STACK_DASHBOARD_URL"); const project = await globalPrismaClient.project.findUnique({ where: { id: auth.project.id }, - select: { stripeAccountId: true }, + select: { onboardingStatus: true, stripeAccountId: true }, }); let stripeAccountId = project?.stripeAccountId || null; - const returnToUrl = new URL(`/projects/${auth.project.id}/payments`, getEnvVariable("NEXT_PUBLIC_STACK_DASHBOARD_URL")).toString(); + const returnToUrl = project?.onboardingStatus === "payments_setup" + ? (() => { + const onboardingUrl = new URL("/new-project", dashboardBaseUrl); + onboardingUrl.searchParams.set("project_id", auth.project.id); + return onboardingUrl.toString(); + })() + : new URL(`/projects/${encodeURIComponent(auth.project.id)}/payments`, dashboardBaseUrl).toString(); if (!stripeAccountId) { const account = await stripe.accounts.create({ diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index bbf812657a..19bf24047a 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -83,6 +83,7 @@ "geist": "^1", "input-otp": "^1.4.1", "jose": "^6.1.3", + "libsodium-wrappers": "^0.8.2", "lodash": "^4.17.21", "next": "16.1.7", "next-themes": "^0.2.1", diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/components.tsx b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/components.tsx new file mode 100644 index 0000000000..9555ee94a7 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/components.tsx @@ -0,0 +1,556 @@ +"use client"; + +import type { CSSProperties, ReactNode } from "react"; + +import { AppIcon } from "@/components/app-square"; +import { DesignAlert } from "@/components/design-components/alert"; +import { DesignBadge } from "@/components/design-components/badge"; +import { DesignButton } from "@/components/design-components/button"; +import { + Alert, + AlertDescription, + AlertTitle, + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + Spinner, + Tooltip, + TooltipContent, + TooltipTrigger, + Typography, + cn, +} from "@/components/ui"; +import { CheckCircleIcon, WarningCircleIcon } from "@phosphor-icons/react"; +import { AdminOwnedProject } from "@stackframe/stack"; +import { ALL_APPS, type AppId } from "@stackframe/stack-shared/dist/apps/apps-config"; +import { previewTemplateSource } from "@stackframe/stack-shared/dist/helpers/emails"; + +import type { TimelineStep } from "./shared"; + +export type OnboardingPageProps = { + stepKey: string, + title: string, + subtitle?: string, + steps: TimelineStep[], + currentStep: TimelineStep["id"], + onStepClick?: (step: TimelineStep["id"]) => void, + disabled?: boolean, + primaryAction: ReactNode, + secondaryAction?: ReactNode, + wide?: boolean, + actionsLayout?: "stacked" | "inline", + children: ReactNode, +}; + +export function OnboardingPage(props: OnboardingPageProps) { + const currentIndex = props.steps.findIndex((step) => step.id === props.currentStep); + + return ( +
+
+
+ + {props.title} + + {props.subtitle != null && ( + + {props.subtitle} + + )} +
+ +
+ {props.children} +
+ +
+ {props.actionsLayout === "inline" ? ( +
+ {props.primaryAction} + {props.secondaryAction != null && props.secondaryAction} +
+ ) : ( +
+ {props.primaryAction} + {props.secondaryAction != null && ( +
+ {props.secondaryAction} +
+ )} +
+ )} +
+
+ +
+
+ {props.steps.map((step, index) => { + const isComplete = index < currentIndex; + const isCurrent = index === currentIndex; + const isClickable = isComplete && !props.disabled && props.onStepClick != null; + + return ( +
+
+
+ ); +} + +function appStageBadgeColor(stage: (typeof ALL_APPS)[AppId]["stage"]) { + if (stage === "alpha") { + return "orange"; + } + if (stage === "beta") { + return "blue"; + } + return null; +} + +export type OnboardingAppCardProps = { + appId: AppId, + selected: boolean, + required: boolean, + primary: boolean, + disabled?: boolean, + onToggle: () => void, +}; + +export function OnboardingAppCard(props: OnboardingAppCardProps) { + const app = ALL_APPS[props.appId]; + const stageBadgeColor = appStageBadgeColor(app.stage); + + return ( + + + + + +
+
+ {app.displayName} + {props.required && ( + + )} + {!props.required && stageBadgeColor != null && ( + + )} +
+ + {app.subtitle} + +
+
+
+ ); +} + +export type DomainSetupTransitionStateProps = { + advancing: boolean, + errorMessage: string | null, + onRetry: () => void, + onOpenProject: () => void, +}; + +export function DomainSetupTransitionState(props: DomainSetupTransitionStateProps) { + if (props.errorMessage == null) { + return ( +
+ +
+ ); + } + + return ( +
+ + + We couldn't continue onboarding + + Retry the automatic transition to email setup, or open the project and continue from there. + + + + + + Domain setup transition failed + {props.errorMessage} + +
+ + +
+
+
+
+ ); +} + +export function OnboardingEmailThemePreview(props: { + adminApp: AdminOwnedProject["app"], + themeId: string, +}) { + const previewHtml = props.adminApp.useEmailPreview({ + themeId: props.themeId, + templateTsxSource: previewTemplateSource, + }); + + return ( +