diff --git a/apps/backend/prisma/migrations/20260312000000_add_project_onboarding_status/migration.sql b/apps/backend/prisma/migrations/20260312000000_add_project_onboarding_status/migration.sql new file mode 100644 index 0000000000..e238be468d --- /dev/null +++ b/apps/backend/prisma/migrations/20260312000000_add_project_onboarding_status/migration.sql @@ -0,0 +1,16 @@ +ALTER TABLE "Project" +ADD COLUMN "onboardingStatus" TEXT NOT NULL DEFAULT 'completed'; + +ALTER TABLE "Project" +ADD CONSTRAINT "Project_onboardingStatus_valid" +CHECK ( + "onboardingStatus" IN ( + 'config_choice', + 'apps_selection', + 'auth_setup', + 'domain_setup', + 'email_theme_setup', + 'payments_setup', + 'completed' + ) +) NOT VALID; diff --git a/apps/backend/prisma/migrations/20260312000000_add_project_onboarding_status/tests/default-and-constraint.ts b/apps/backend/prisma/migrations/20260312000000_add_project_onboarding_status/tests/default-and-constraint.ts new file mode 100644 index 0000000000..bb7d27d7ab --- /dev/null +++ b/apps/backend/prisma/migrations/20260312000000_add_project_onboarding_status/tests/default-and-constraint.ts @@ -0,0 +1,56 @@ +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") + VALUES (${projectId}, NOW(), NOW(), 'Onboarding Test', '', false) + `; + return { projectId }; +}; + +export const postMigration = async (sql: Sql, ctx: Awaited>) => { + const rows = await sql` + SELECT "onboardingStatus" + FROM "Project" + WHERE "id" = ${ctx.projectId} + `; + expect(rows).toHaveLength(1); + expect(rows[0].onboardingStatus).toBe("completed"); + + const validProjectId = `test-${randomUUID()}`; + await sql` + INSERT INTO "Project" ( + "id", + "createdAt", + "updatedAt", + "displayName", + "description", + "isProductionMode", + "onboardingStatus" + ) + VALUES (${validProjectId}, NOW(), NOW(), 'Valid Status Project', '', false, 'auth_setup') + `; + + const invalidProjectId = `test-${randomUUID()}`; + await expect(sql` + INSERT INTO "Project" ( + "id", + "createdAt", + "updatedAt", + "displayName", + "description", + "isProductionMode", + "onboardingStatus" + ) + VALUES (${invalidProjectId}, NOW(), NOW(), 'Invalid Status Project', '', 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/20260312000001_validate_project_onboarding_status_constraint/migration.sql b/apps/backend/prisma/migrations/20260312000001_validate_project_onboarding_status_constraint/migration.sql new file mode 100644 index 0000000000..4454dcd782 --- /dev/null +++ b/apps/backend/prisma/migrations/20260312000001_validate_project_onboarding_status_constraint/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "Project" +VALIDATE CONSTRAINT "Project_onboardingStatus_valid"; diff --git a/apps/backend/prisma/migrations/20260312000001_validate_project_onboarding_status_constraint/tests/constraint-is-validated.ts b/apps/backend/prisma/migrations/20260312000001_validate_project_onboarding_status_constraint/tests/constraint-is-validated.ts new file mode 100644 index 0000000000..90c7ff5d45 --- /dev/null +++ b/apps/backend/prisma/migrations/20260312000001_validate_project_onboarding_status_constraint/tests/constraint-is-validated.ts @@ -0,0 +1,13 @@ +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/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 17760a2430..0d408ff0ba 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -26,6 +26,7 @@ model Project { description String @default("") isProductionMode Boolean ownerTeamId String? @db.Uuid + onboardingStatus String @default("completed") logoUrl String? logoFullUrl String? diff --git a/apps/backend/src/lib/projects.tsx b/apps/backend/src/lib/projects.tsx index 78b6db54a1..c654691781 100644 --- a/apps/backend/src/lib/projects.tsx +++ b/apps/backend/src/lib/projects.tsx @@ -66,6 +66,7 @@ export function getProjectQuery(projectId: string): RawQuery { + const onboardingStatusColumnExistsRows = await tx.$queryRaw>` + SELECT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'Project' + AND column_name = 'onboardingStatus' + ) AS "exists" + `; + const onboardingStatusColumnExists = onboardingStatusColumnExistsRows[0]?.exists === true; + let project: Prisma.ProjectGetPayload<{}>; let branchId: string; if (options.type === "create") { branchId = DEFAULT_BRANCH_ID; + const createData: Prisma.ProjectCreateInput = { + id: options.projectId ?? generateUuid(), + displayName: options.data.display_name, + description: options.data.description ?? "", + isProductionMode: options.data.is_production_mode ?? false, + ownerTeamId: options.data.owner_team_id, + logoUrl: logoUrls['logo_url'], + logoFullUrl: logoUrls['logo_full_url'], + logoDarkModeUrl: logoUrls['logo_dark_mode_url'], + logoFullDarkModeUrl: logoUrls['logo_full_dark_mode_url'], + }; + if (onboardingStatusColumnExists && options.data.onboarding_status !== undefined) { + createData.onboardingStatus = options.data.onboarding_status; + } project = await tx.project.create({ - data: { - id: options.projectId ?? generateUuid(), - displayName: options.data.display_name, - description: options.data.description ?? "", - isProductionMode: options.data.is_production_mode ?? false, - ownerTeamId: options.data.owner_team_id, - logoUrl: logoUrls['logo_url'], - logoFullUrl: logoUrls['logo_full_url'], - logoDarkModeUrl: logoUrls['logo_dark_mode_url'], - logoFullDarkModeUrl: logoUrls['logo_full_dark_mode_url'], - }, + data: createData, }); await tx.tenancy.create({ @@ -138,19 +154,24 @@ export async function createOrUpdateProjectWithLegacyConfig( throw new KnownErrors.ProjectNotFound(options.projectId); } + const updateData: Prisma.ProjectUpdateInput = { + displayName: options.data.display_name, + description: options.data.description === null ? "" : options.data.description, + isProductionMode: options.data.is_production_mode, + logoUrl: logoUrls['logo_url'], + logoFullUrl: logoUrls['logo_full_url'], + logoDarkModeUrl: logoUrls['logo_dark_mode_url'], + logoFullDarkModeUrl: logoUrls['logo_full_dark_mode_url'], + }; + if (onboardingStatusColumnExists && options.data.onboarding_status !== undefined) { + updateData.onboardingStatus = options.data.onboarding_status; + } + project = await tx.project.update({ where: { id: projectFound.id, }, - data: { - displayName: options.data.display_name, - description: options.data.description === null ? "" : options.data.description, - isProductionMode: options.data.is_production_mode, - logoUrl: logoUrls['logo_url'], - logoFullUrl: logoUrls['logo_full_url'], - logoDarkModeUrl: logoUrls['logo_dark_mode_url'], - logoFullDarkModeUrl: logoUrls['logo_full_dark_mode_url'], - }, + data: updateData, }); branchId = options.branchId; } diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client.tsx index ff12f51813..b3a64078d4 100644 --- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client.tsx @@ -1,59 +1,1416 @@ 'use client'; -import { FieldLabel, InputField, SwitchListField } from "@/components/form-fields"; + +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 { DesignCard } from "@/components/design-components/card"; +import { DesignInput } from "@/components/design-components/input"; +import { DesignSelectorDropdown } from "@/components/design-components/select"; import { useRouter } from "@/components/router"; -import { yupResolver } from "@hookform/resolvers/yup"; +import { + Alert, + AlertDescription, + AlertTitle, + BrowserFrame, + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Spinner, + Switch, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, + Typography, + cn, +} from "@/components/ui"; +import { useUpdateConfig } from "@/lib/config-update"; import { getPublicEnvVar } from "@/lib/env"; -import { AuthPage, TeamSwitcher, useUser } from "@stackframe/stack"; +import { stackAppInternalsSymbol } from "@/lib/stack-app-internals"; +import { + ArrowLeftIcon, + ArrowsClockwiseIcon, + ChartBarIcon, + CheckCircleIcon, + LightningIcon, + LinkBreakIcon, + PlusCircleIcon, + ShieldIcon, + StripeLogoIcon, + WalletIcon, + WarningCircleIcon, + WebhooksLogoIcon +} from "@phosphor-icons/react"; +import { AdminOwnedProject, AuthPage, useStackApp, useUser } 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 { projectOnboardingStatusValues, type ProjectOnboardingStatus } from "@stackframe/stack-shared/dist/schema-fields"; +import { captureError } from "@stackframe/stack-shared/dist/utils/errors"; import { allProviders } from "@stackframe/stack-shared/dist/utils/oauth"; import { runAsynchronouslyWithAlert, wait } from "@stackframe/stack-shared/dist/utils/promises"; -import { BrowserFrame, Button, Form, FormControl, FormField, FormItem, FormMessage, Separator, Typography } from "@/components/ui"; +import { stringCompare } from "@stackframe/stack-shared/dist/utils/strings"; import { useSearchParams } from "next/navigation"; -import { useState } from "react"; -import { useForm } from "react-hook-form"; -import * as yup from "yup"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +const PROJECT_ONBOARDING_STATUSES = projectOnboardingStatusValues; + +type SignInMethod = "credential" | "magicLink" | "passkey" | "google" | "github" | "microsoft"; + +const SIGN_IN_METHODS: Array<{ id: SignInMethod, label: string }> = [ + { id: "credential", label: "Email & password" }, + { id: "magicLink", label: "Magic link / OTP" }, + { id: "passkey", label: "Passkey" }, + { id: "google", label: "Google" }, + { id: "github", label: "GitHub" }, + { id: "microsoft", label: "Microsoft" }, +]; + +const REQUIRED_APP_IDS: AppId[] = ["authentication", "emails"]; +const PRIMARY_APP_IDS: AppId[] = ["authentication", "emails", "payments", "analytics"]; +const ALL_APP_IDS = Object.keys(ALL_APPS) as AppId[]; + +type StackAppInternals = { + sendRequest: (path: string, requestOptions: RequestInit, requestType?: "client" | "server" | "admin") => Promise, + refreshOwnedProjects: () => Promise, +}; + +type TimelineStep = { + id: ProjectOnboardingStatus, + label: string, +}; + +const PAYMENT_COUNTRY_OPTIONS = [ + { value: "US", label: "United States" }, + { value: "OTHER", label: "Other" }, +] as const; + +function isStackAppInternals(value: unknown): value is StackAppInternals { + return ( + value != null + && typeof value === "object" + && "sendRequest" in value + && typeof value.sendRequest === "function" + && "refreshOwnedProjects" in value + && typeof value.refreshOwnedProjects === "function" + ); +} + +function getStackAppInternals(appValue: unknown): StackAppInternals { + if (appValue == null || typeof appValue !== "object") { + throw new Error("The Stack app instance is unavailable."); + } + + const internals = Reflect.get(appValue, stackAppInternalsSymbol); + if (!isStackAppInternals(internals)) { + throw new Error("The Stack client app cannot send internal requests."); + } + + return internals; +} + +function isProjectOnboardingStatus(value: unknown): value is ProjectOnboardingStatus { + return typeof value === "string" && PROJECT_ONBOARDING_STATUSES.some((status) => status === value); +} + +function orderedAppIds() { + const primarySet = new Set(PRIMARY_APP_IDS); + const secondary = ALL_APP_IDS.filter((appId) => !primarySet.has(appId)).sort((a, b) => { + return stringCompare(ALL_APPS[a].displayName, ALL_APPS[b].displayName); + }); + return [...PRIMARY_APP_IDS, ...secondary]; +} + +function normalizeTrustedDomain(input: string): string { + const trimmed = input.trim(); + if (trimmed.length === 0) { + return ""; + } + + let parsed: URL; + try { + parsed = new URL(trimmed); + } catch { + throw new Error("Trusted domain must be a valid URL."); + } + + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { + throw new Error("Trusted domain must start with http:// or https://."); + } + + return parsed.toString().replace(/\/$/, ""); +} + +function buildTimeline(includePayments: boolean): TimelineStep[] { + const timeline: TimelineStep[] = [ + { id: "config_choice", label: "Config" }, + { id: "apps_selection", label: "Apps" }, + { id: "auth_setup", label: "Auth" }, + { id: "email_theme_setup", label: "Email Theme" }, + ]; + + if (includePayments) { + timeline.push({ id: "payments_setup", label: "Payments" }); + } + + timeline.push({ id: "completed", label: "Finish" }); + return timeline; +} + +function deriveInitialSignInMethods(project: AdminOwnedProject, status: ProjectOnboardingStatus): Set { + const config = project.config; + const methods = new Set(); + + if (config.credentialEnabled) { + methods.add("credential"); + } + if (config.magicLinkEnabled) { + methods.add("magicLink"); + } + if (config.passkeyEnabled) { + methods.add("passkey"); + } + + for (const provider of config.oauthProviders) { + if (provider.id === "google" || provider.id === "github" || provider.id === "microsoft") { + methods.add(provider.id); + } + } + + const hasDefaultUntouchedAuthConfig = ( + config.credentialEnabled + && !config.magicLinkEnabled + && !config.passkeyEnabled + && config.oauthProviders.length === 0 + ); + const isInEarlyOnboardingStep = ( + status === "config_choice" + || status === "apps_selection" + || status === "auth_setup" + ); + if (hasDefaultUntouchedAuthConfig && isInEarlyOnboardingStep) { + methods.add("credential"); + methods.add("magicLink"); + methods.add("google"); + } + + return methods; +} + +function deriveInitialApps(config: ReturnType): Set { + const enabledApps = new Set(); + + for (const appId of ALL_APP_IDS) { + if (config.apps.installed[appId]?.enabled) { + enabledApps.add(appId); + } + } + + if (enabledApps.size === 0) { + for (const primaryAppId of PRIMARY_APP_IDS) { + enabledApps.add(primaryAppId); + } + } + + for (const requiredAppId of REQUIRED_APP_IDS) { + enabledApps.add(requiredAppId); + } + + return enabledApps; +} + +function getStepIndex(steps: TimelineStep[], stepId: ProjectOnboardingStatus) { + return steps.findIndex((step) => step.id === stepId); +} + +function OnboardingTimeline(props: { + steps: TimelineStep[], + currentStep: ProjectOnboardingStatus, + onStepClick?: (step: ProjectOnboardingStatus) => void, + disabled?: boolean, +}) { + const currentIndex = props.steps.findIndex((step) => step.id === props.currentStep); + + return ( +
+
+ {props.steps.map((step, index) => { + const isComplete = index < currentIndex; + const isCurrent = index === currentIndex; + const isClickable = isComplete && !props.disabled && props.onStepClick != null; + const circleClassName = isComplete + ? "bg-green-500 text-white" + : isCurrent + ? "bg-blue-600 text-white" + : "bg-muted text-muted-foreground"; + + return ( +
+ {isClickable ? ( + + ) : ( +
+
+ {isComplete ? : index + 1} +
+ {step.label} +
+ )} + {index < props.steps.length - 1 &&
} +
+ ); + })} +
+
+ ); +} + +function appStageBadgeColor(stage: (typeof ALL_APPS)[AppId]["stage"]) { + if (stage === "alpha") { + return "orange"; + } + if (stage === "beta") { + return "blue"; + } + return null; +} + +function appStageLabel(stage: (typeof ALL_APPS)[AppId]["stage"]) { + if (stage === "alpha") { + return "Alpha"; + } + if (stage === "beta") { + return "Beta"; + } + return null; +} + +function OnboardingAppCard(props: { + appId: AppId, + selected: boolean, + required: boolean, + primary: boolean, + disabled?: boolean, + onToggle: () => void, +}) { + const app = ALL_APPS[props.appId]; + const stageBadgeColor = appStageBadgeColor(app.stage); + const stageLabel = appStageLabel(app.stage); + + return ( + + + + + +
+
+ {app.displayName} + {stageBadgeColor && ( + + )} +
+ + {app.subtitle} + +
+
+
+ ); +} + +function OnboardingEmailThemePreview(props: { + adminApp: AdminOwnedProject["app"], + themeId: string, +}) { + const previewHtml = props.adminApp.useEmailPreview({ + themeId: props.themeId, + templateTsxSource: previewTemplateSource, + }); + + return ( +