mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
<!-- Make sure you've read the CONTRIBUTING.md guidelines: https://github.com/stack-auth/stack-auth/blob/dev/CONTRIBUTING.md --> ## Summary This PR brings the Payments dashboard surfaces in line with the shared design system: product creation, product-line / included-item dialogs, auth-method toggles, payments empty states, and related layout polish. Dialogs migrate from raw shadcn `Dialog` to `DesignDialog` with consistent headers, footers, inputs, and selector dropdowns. **Base:** `dev` → **Head:** `Payments-app-design-fixes` **Scope:** 31 files, ~+1.4k / −1.3k lines **Captured on:** local dev server (`internal` project), signed in as `admin@example.com` ## Screenshots Captured from `http://localhost:8101` (viewport: **1920×1200** standard, **2560×1440** widescreen). Assets hosted in [this gist](https://gist.github.com/mantrakp04/ca3483d2b66b8e28f0872488df573ccf). > Red outlines on the **after** shots mark the new or changed UI introduced by this PR. ### Create Product — payments form redesign | | Before | After | | --- | --- | --- | | Light |  |  | | Dark |  |  | Widescreen: | | Before | After | | --- | --- | --- | | Light |  |  | | Dark |  |  | ### Product Lines onboarding — vertical centering fix | | Before | After | | --- | --- | --- | | Light |  |  | | Dark |  |  | ### Create Product Line dialog — `DesignDialog` migration | | Before | After | | --- | --- | --- | | Light | *(legacy shadcn dialog on `dev` — open via Product Line → Create new)* |  | | Dark | |  | ### Auth Methods — toggle row accessibility | | Before | After | | --- | --- | --- | | Light |  |  | | Dark |  |  | ### Other migrated surfaces (after only) | Page | Light | Dark | | --- | --- | --- | | Payments settings |  |  | | Sign-up rules |  |  | | Projects list (Create Project button) |  |  | | Playground / DesignDialog |  |  | | Included Item dialog |  |  | ### Scroll behaviour — Sign-up Rules | | Light | Dark | | --- | --- | --- | | Scroll |  |  | ## What's new - **`DesignDialog`** extended with `customHeader`, `noBodyPadding`, and section `className` hooks; Playground updated to showcase them. - **Payments dialogs** (`CreateProductLineDialog`, `IncludedItemDialog`, price edit, item dialog) migrated to design-system components. - **Create Product** page uses `DesignButton`, `DesignInput`, `DesignSelectorDropdown`, and refreshed header actions. - **Auth Methods** toggle rows use semantic `<Label htmlFor>` instead of click-capture divs. - **Payments layout** empty-state card centers correctly; product-lines onboarding slideshow vertically centers. - **Backend** seed invariant for Growth product price; removed unused import in product switch route. ## Notes for reviewers - Dialog migrations preserve validation + async error handling (`runAsynchronouslyWithAlert` where applicable). - Included-item dialog uses a sentinel value for “Create new item” to avoid colliding with real item IDs. - `packages/stack` / `packages/js` are untouched; template + dashboard-ui-components carry SDK-facing dialog changes. ## Test plan - [x] Visual capture on `internal` project (`admin@example.com`) — light/dark, standard + widescreen - [ ] Create product flow: customer type → product line dropdown → create line dialog - [ ] Add included item dialog from create/edit product - [ ] Auth Methods toggles (label click + switch) - [ ] Payments product-lines onboarding slideshow at varied viewport heights - [ ] `pnpm typecheck` / `pnpm lint` / targeted E2E if API surface changed --------- Co-authored-by: nams1570 <amanganapathy@gmail.com> Co-authored-by: mantrakp04 <mantrakp@gmail.com> Co-authored-by: Mantra <87142457+mantrakp04@users.noreply.github.com>
673 lines
26 KiB
TypeScript
673 lines
26 KiB
TypeScript
/* eslint-disable no-restricted-syntax */
|
|
import { usersCrudHandlers } from '@/app/api/latest/users/crud';
|
|
import { CustomerType, Prisma, PurchaseCreationSource, SubscriptionStatus } from '@/generated/prisma/client';
|
|
import { overrideBranchConfigOverride } from '@/lib/config';
|
|
import {
|
|
LOCAL_EMULATOR_ADMIN_EMAIL,
|
|
LOCAL_EMULATOR_ADMIN_PASSWORD,
|
|
LOCAL_EMULATOR_ADMIN_USER_ID,
|
|
LOCAL_EMULATOR_OWNER_TEAM_ID,
|
|
isLocalEmulatorEnabled,
|
|
} from '@/lib/local-emulator';
|
|
import { ensurePermissionDefinition, grantTeamPermission } from '@/lib/permissions';
|
|
import { createOrUpdateProjectWithLegacyConfig, getProject } from '@/lib/projects';
|
|
import { seedDummyProject } from '@/lib/seed-dummy-data';
|
|
import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch } from '@/lib/tenancies';
|
|
import { getPrismaClientForTenancy, globalPrismaClient } from '@/prisma-client';
|
|
import { ALL_APPS } from '@stackframe/stack-shared/dist/apps/apps-config';
|
|
import { DEFAULT_EMAIL_THEME_ID } from '@stackframe/stack-shared/dist/helpers/emails';
|
|
import { AdminUserProjectsCrud } from '@stackframe/stack-shared/dist/interface/crud/projects';
|
|
import { ITEM_IDS, PLAN_LIMITS } from '@stackframe/stack-shared/dist/plans';
|
|
import { DayInterval } from '@stackframe/stack-shared/dist/utils/dates';
|
|
import { throwErr } from '@stackframe/stack-shared/dist/utils/errors';
|
|
import { typedEntries, typedFromEntries } from '@stackframe/stack-shared/dist/utils/objects';
|
|
|
|
const MONTHLY_REPEAT: DayInterval = [1, "month"];
|
|
|
|
const DUMMY_PROJECT_ID = '6fbbf22e-f4b2-4c6e-95a1-beab6fa41063';
|
|
const DEVELOPMENT_ENVIRONMENT_PROJECT_ID = '5f2a45c8-9096-4f0b-b987-7640a47f7a79';
|
|
|
|
let didEnableSeedLogTimestamps = false;
|
|
|
|
function enableSeedLogTimestamps() {
|
|
if (didEnableSeedLogTimestamps) return;
|
|
didEnableSeedLogTimestamps = true;
|
|
|
|
const originalLog = console.log.bind(console);
|
|
const originalInfo = console.info.bind(console);
|
|
const originalWarn = console.warn.bind(console);
|
|
const originalError = console.error.bind(console);
|
|
|
|
const withTimestamp = (...data: unknown[]) => [`[${new Date().toISOString()}]`, ...data];
|
|
|
|
console.log = (...data: Parameters<typeof console.log>) => {
|
|
originalLog(...withTimestamp(...data));
|
|
};
|
|
console.info = (...data: Parameters<typeof console.info>) => {
|
|
originalInfo(...withTimestamp(...data));
|
|
};
|
|
console.warn = (...data: Parameters<typeof console.warn>) => {
|
|
originalWarn(...withTimestamp(...data));
|
|
};
|
|
console.error = (...data: Parameters<typeof console.error>) => {
|
|
originalError(...withTimestamp(...data));
|
|
};
|
|
}
|
|
|
|
export async function seed() {
|
|
enableSeedLogTimestamps();
|
|
process.env.STACK_SEED_MODE = 'true';
|
|
console.log('Seeding database...');
|
|
|
|
// Optional default admin user
|
|
const adminEmail = process.env.STACK_SEED_INTERNAL_PROJECT_USER_EMAIL;
|
|
const adminPassword = process.env.STACK_SEED_INTERNAL_PROJECT_USER_PASSWORD;
|
|
const adminInternalAccess = process.env.STACK_SEED_INTERNAL_PROJECT_USER_INTERNAL_ACCESS === 'true';
|
|
const adminGithubId = process.env.STACK_SEED_INTERNAL_PROJECT_USER_GITHUB_ID;
|
|
|
|
// dashboard settings
|
|
const dashboardDomain = process.env.NEXT_PUBLIC_STACK_DASHBOARD_URL;
|
|
const oauthProviderIds = process.env.STACK_SEED_INTERNAL_PROJECT_OAUTH_PROVIDERS?.split(',') ?? [];
|
|
const otpEnabled = process.env.STACK_SEED_INTERNAL_PROJECT_OTP_ENABLED === 'true';
|
|
const signUpEnabled = process.env.STACK_SEED_INTERNAL_PROJECT_SIGN_UP_ENABLED === 'true';
|
|
const allowLocalhost = process.env.STACK_SEED_INTERNAL_PROJECT_ALLOW_LOCALHOST === 'true';
|
|
|
|
const localEmulatorEnabled = isLocalEmulatorEnabled();
|
|
|
|
const apiKeyId = '3142e763-b230-44b5-8636-aa62f7489c26';
|
|
const defaultUserId = '33e7c043-d2d1-4187-acd3-f91b5ed64b46';
|
|
const internalTeamId = 'a23e1b7f-ab18-41fc-9ee6-7a9ca9fa543c';
|
|
let internalProject = await getProject('internal');
|
|
|
|
if (!internalProject) {
|
|
internalProject = await createOrUpdateProjectWithLegacyConfig({
|
|
type: 'create',
|
|
projectId: 'internal',
|
|
data: {
|
|
display_name: 'Stack Dashboard',
|
|
owner_team_id: internalTeamId,
|
|
description: 'Stack\'s admin dashboard',
|
|
is_production_mode: false,
|
|
config: {
|
|
allow_localhost: true,
|
|
oauth_providers: oauthProviderIds.map((id) => ({
|
|
id: id as any,
|
|
type: 'shared',
|
|
})),
|
|
sign_up_enabled: signUpEnabled,
|
|
credential_enabled: true,
|
|
magic_link_enabled: otpEnabled,
|
|
},
|
|
},
|
|
});
|
|
|
|
console.log('Internal project created');
|
|
}
|
|
|
|
const internalTenancy = await getSoleTenancyFromProjectBranch("internal", DEFAULT_BRANCH_ID);
|
|
const internalPrisma = await getPrismaClientForTenancy(internalTenancy);
|
|
|
|
internalProject = await createOrUpdateProjectWithLegacyConfig({
|
|
projectId: 'internal',
|
|
branchId: DEFAULT_BRANCH_ID,
|
|
type: 'update',
|
|
data: {
|
|
config: {
|
|
create_team_on_sign_up: true,
|
|
sign_up_enabled: signUpEnabled,
|
|
magic_link_enabled: otpEnabled,
|
|
allow_localhost: allowLocalhost,
|
|
client_team_creation_enabled: true,
|
|
domains: [
|
|
...(dashboardDomain && new URL(dashboardDomain).hostname !== 'localhost' ? [{ domain: dashboardDomain, handler_path: '/handler' }] : []),
|
|
...Object.values(internalTenancy.config.domains.trustedDomains)
|
|
.filter((d) => d.baseUrl !== dashboardDomain && d.baseUrl)
|
|
.map((d) => ({ domain: d.baseUrl || throwErr('Domain base URL is required'), handler_path: d.handlerPath })),
|
|
],
|
|
},
|
|
},
|
|
});
|
|
|
|
await overrideBranchConfigOverride({
|
|
projectId: 'internal',
|
|
branchId: DEFAULT_BRANCH_ID,
|
|
branchConfigOverrideOverride: {
|
|
// Disable email verification for internal project - dashboard admins shouldn't need to verify their email
|
|
onboarding: {
|
|
requireEmailVerification: false,
|
|
},
|
|
dataVault: {
|
|
stores: {
|
|
'neon-connection-strings': {
|
|
displayName: 'Neon Connection Strings',
|
|
}
|
|
}
|
|
},
|
|
payments: {
|
|
productLines: {
|
|
plans: {
|
|
displayName: "Plans",
|
|
customerType: "team",
|
|
},
|
|
},
|
|
products: {
|
|
free: {
|
|
productLineId: "plans",
|
|
displayName: "Free",
|
|
customerType: "team",
|
|
serverOnly: false,
|
|
stackable: false,
|
|
prices: {
|
|
"free-monthly": {
|
|
USD: "0",
|
|
interval: [1, "month"] as any,
|
|
},
|
|
},
|
|
includedItems: {
|
|
[ITEM_IDS.seats]: { quantity: PLAN_LIMITS.free.seats, repeat: "never" as const, expires: "when-purchase-expires" as const },
|
|
[ITEM_IDS.authUsers]: { quantity: PLAN_LIMITS.free.authUsers, repeat: "never" as const, expires: "when-purchase-expires" as const },
|
|
[ITEM_IDS.emailsPerMonth]: { quantity: PLAN_LIMITS.free.emailsPerMonth, repeat: MONTHLY_REPEAT, expires: "when-repeated" as const },
|
|
[ITEM_IDS.analyticsTimeoutSeconds]: { quantity: PLAN_LIMITS.free.analyticsTimeoutSeconds, repeat: "never" as const, expires: "when-purchase-expires" as const },
|
|
[ITEM_IDS.analyticsEvents]: { quantity: PLAN_LIMITS.free.analyticsEvents, repeat: MONTHLY_REPEAT, expires: "when-repeated" as const },
|
|
[ITEM_IDS.sessionReplays]: { quantity: PLAN_LIMITS.free.sessionReplays, repeat: MONTHLY_REPEAT, expires: "when-repeated" as const },
|
|
},
|
|
},
|
|
team: {
|
|
productLineId: "plans",
|
|
displayName: "Team",
|
|
customerType: "team",
|
|
serverOnly: false,
|
|
stackable: false,
|
|
prices: {
|
|
monthly: {
|
|
USD: "49",
|
|
interval: MONTHLY_REPEAT,
|
|
serverOnly: false,
|
|
},
|
|
},
|
|
includedItems: {
|
|
[ITEM_IDS.seats]: { quantity: PLAN_LIMITS.team.seats, repeat: "never" as const, expires: "when-purchase-expires" as const },
|
|
[ITEM_IDS.authUsers]: { quantity: PLAN_LIMITS.team.authUsers, repeat: "never" as const, expires: "when-purchase-expires" as const },
|
|
[ITEM_IDS.emailsPerMonth]: { quantity: PLAN_LIMITS.team.emailsPerMonth, repeat: MONTHLY_REPEAT, expires: "when-repeated" as const },
|
|
[ITEM_IDS.analyticsTimeoutSeconds]: { quantity: PLAN_LIMITS.team.analyticsTimeoutSeconds, repeat: "never" as const, expires: "when-purchase-expires" as const },
|
|
[ITEM_IDS.analyticsEvents]: { quantity: PLAN_LIMITS.team.analyticsEvents, repeat: MONTHLY_REPEAT, expires: "when-repeated" as const },
|
|
[ITEM_IDS.sessionReplays]: { quantity: PLAN_LIMITS.team.sessionReplays, repeat: MONTHLY_REPEAT, expires: "when-repeated" as const },
|
|
[ITEM_IDS.onboardingCall]: { quantity: 1, repeat: "never" as const, expires: "when-purchase-expires" as const },
|
|
},
|
|
},
|
|
growth: {
|
|
productLineId: "plans",
|
|
displayName: "Growth",
|
|
customerType: "team",
|
|
serverOnly: false,
|
|
stackable: false,
|
|
prices: {
|
|
monthly: {
|
|
USD: "299",
|
|
interval: MONTHLY_REPEAT,
|
|
serverOnly: false,
|
|
},
|
|
},
|
|
includedItems: {
|
|
[ITEM_IDS.seats]: { quantity: PLAN_LIMITS.growth.seats, repeat: "never" as const, expires: "when-purchase-expires" as const },
|
|
[ITEM_IDS.authUsers]: { quantity: PLAN_LIMITS.growth.authUsers, repeat: "never" as const, expires: "when-purchase-expires" as const },
|
|
[ITEM_IDS.emailsPerMonth]: { quantity: PLAN_LIMITS.growth.emailsPerMonth, repeat: MONTHLY_REPEAT, expires: "when-repeated" as const },
|
|
[ITEM_IDS.analyticsTimeoutSeconds]: { quantity: PLAN_LIMITS.growth.analyticsTimeoutSeconds, repeat: "never" as const, expires: "when-purchase-expires" as const },
|
|
[ITEM_IDS.analyticsEvents]: { quantity: PLAN_LIMITS.growth.analyticsEvents, repeat: MONTHLY_REPEAT, expires: "when-repeated" as const },
|
|
[ITEM_IDS.sessionReplays]: { quantity: PLAN_LIMITS.growth.sessionReplays, repeat: MONTHLY_REPEAT, expires: "when-repeated" as const },
|
|
[ITEM_IDS.onboardingCall]: { quantity: 1, repeat: "never" as const, expires: "when-purchase-expires" as const },
|
|
},
|
|
},
|
|
"extra-seats": {
|
|
productLineId: "plans",
|
|
displayName: "Extra Seats",
|
|
customerType: "team",
|
|
serverOnly: false,
|
|
stackable: true,
|
|
prices: {
|
|
monthly: {
|
|
USD: "29",
|
|
interval: MONTHLY_REPEAT,
|
|
serverOnly: false,
|
|
},
|
|
},
|
|
includedItems: {
|
|
[ITEM_IDS.seats]: { quantity: 1, repeat: "never" as const, expires: "when-purchase-expires" as const },
|
|
},
|
|
isAddOnTo: {
|
|
team: true,
|
|
growth: true,
|
|
},
|
|
},
|
|
},
|
|
items: {
|
|
[ITEM_IDS.seats]: { displayName: "Dashboard Admins", customerType: "team" as const },
|
|
[ITEM_IDS.authUsers]: { displayName: "Auth Users", customerType: "team" as const },
|
|
[ITEM_IDS.emailsPerMonth]: { displayName: "Emails per Month", customerType: "team" as const },
|
|
[ITEM_IDS.analyticsTimeoutSeconds]: { displayName: "Analytics Timeout (seconds)", customerType: "team" as const },
|
|
[ITEM_IDS.analyticsEvents]: { displayName: "Analytics Events", customerType: "team" as const },
|
|
[ITEM_IDS.sessionReplays]: { displayName: "Session Replays", customerType: "team" as const },
|
|
[ITEM_IDS.onboardingCall]: { displayName: "Onboarding Call", customerType: "team" as const },
|
|
},
|
|
},
|
|
apps: {
|
|
installed: typedFromEntries(typedEntries(ALL_APPS).map(([key, value]) => [key, { enabled: true }])),
|
|
},
|
|
}
|
|
});
|
|
|
|
await ensurePermissionDefinition(
|
|
globalPrismaClient,
|
|
internalPrisma,
|
|
{
|
|
id: "team_member",
|
|
scope: "team",
|
|
tenancy: internalTenancy,
|
|
data: {
|
|
description: "1",
|
|
contained_permission_ids: ["$read_members"],
|
|
}
|
|
}
|
|
);
|
|
const updatedInternalTenancy = await getSoleTenancyFromProjectBranch("internal", DEFAULT_BRANCH_ID);
|
|
await ensurePermissionDefinition(
|
|
globalPrismaClient,
|
|
internalPrisma,
|
|
{
|
|
id: "team_admin",
|
|
scope: "team",
|
|
tenancy: updatedInternalTenancy,
|
|
data: {
|
|
description: "2",
|
|
contained_permission_ids: ["$read_members", "$remove_members", "$update_team"],
|
|
}
|
|
}
|
|
);
|
|
|
|
|
|
const internalTeam = await internalPrisma.team.findUnique({
|
|
where: {
|
|
tenancyId_teamId: {
|
|
tenancyId: internalTenancy.id,
|
|
teamId: internalTeamId,
|
|
},
|
|
},
|
|
});
|
|
if (!internalTeam) {
|
|
await internalPrisma.team.create({
|
|
data: {
|
|
tenancyId: internalTenancy.id,
|
|
teamId: internalTeamId,
|
|
displayName: 'Internal Team',
|
|
mirroredProjectId: 'internal',
|
|
mirroredBranchId: DEFAULT_BRANCH_ID,
|
|
},
|
|
});
|
|
console.log('Internal team created');
|
|
}
|
|
|
|
// The team-create CRUD path auto-grants the free plan to every team in the
|
|
// internal project, but the internal team itself is written directly above
|
|
// (bypassing that code path), so it would otherwise end up with zero
|
|
// entitlements and trip the plan-limit enforcement. Grant it the Growth plan
|
|
// so Stack Auth employees using the dashboard get full quotas. Idempotent —
|
|
// skipped if an active Growth subscription already exists.
|
|
//
|
|
// We create the subscription with raw Prisma (matching seed-dummy-data.ts)
|
|
// rather than grantProductToCustomer because bulldozer storage tables
|
|
// aren't initialized at this point in the seed yet. The Bulldozer init
|
|
// call right below this block ingresses the row into the ledger.
|
|
const growthProduct = updatedInternalTenancy.config.payments.products.growth;
|
|
if (growthProduct.customerType === 'team') {
|
|
const existingGrowthSub = await internalPrisma.subscription.findFirst({
|
|
where: {
|
|
tenancyId: internalTenancy.id,
|
|
customerId: internalTeamId,
|
|
customerType: CustomerType.TEAM,
|
|
productId: 'growth',
|
|
status: SubscriptionStatus.active,
|
|
},
|
|
});
|
|
if (!existingGrowthSub) {
|
|
const firstPriceId = Object.keys(growthProduct.prices)[0];
|
|
if (!firstPriceId) {
|
|
throw new Error("Internal seed invariant violated: the Growth product must have at least one price configured before seeding the internal team subscription.");
|
|
}
|
|
const now = new Date();
|
|
// Clone to ensure the stored JSON snapshot is independent of the config object
|
|
// (mirrors the pattern used in seed-dummy-data.ts).
|
|
const storedProduct = JSON.parse(JSON.stringify(growthProduct)) as Prisma.InputJsonValue;
|
|
// Mirror what a real Stripe checkout would produce, based on whether
|
|
// the internal project is running in test mode.
|
|
const creationSource = updatedInternalTenancy.config.payments.testMode
|
|
? PurchaseCreationSource.TEST_MODE
|
|
: PurchaseCreationSource.PURCHASE_PAGE;
|
|
await internalPrisma.subscription.create({
|
|
data: {
|
|
tenancyId: internalTenancy.id,
|
|
customerId: internalTeamId,
|
|
customerType: CustomerType.TEAM,
|
|
status: SubscriptionStatus.active,
|
|
productId: 'growth',
|
|
priceId: firstPriceId,
|
|
product: storedProduct,
|
|
quantity: 1,
|
|
currentPeriodStart: now,
|
|
currentPeriodEnd: new Date('2099-12-31T23:59:59Z'),
|
|
cancelAtPeriodEnd: false,
|
|
creationSource,
|
|
},
|
|
});
|
|
console.log('Granted Growth plan to internal team');
|
|
}
|
|
}
|
|
|
|
// Upsert the internal API key set before any flake-prone work (dummy-project
|
|
// seed, email/svix, clickhouse). The emulator CLI authenticates against the
|
|
// internal project using the pck stored here, so it must land before the rest
|
|
// of the seed even if something later fails.
|
|
const isLocalEmulator = process.env.NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR === 'true';
|
|
const rawPck = process.env.STACK_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY;
|
|
if (isLocalEmulator && !rawPck) {
|
|
// Emulator images build before a per-VM pck is available. Runtime boots set
|
|
// STACK_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY from the VM-generated
|
|
// random value and re-run the seed, which upserts the internal key set then.
|
|
console.log('Skipping internal API key set (no pck provided; emulator mode).');
|
|
} else {
|
|
const keySet = {
|
|
publishableClientKey: rawPck || throwErr('STACK_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY is not set'),
|
|
secretServerKey: isLocalEmulator
|
|
? (process.env.STACK_INTERNAL_PROJECT_SECRET_SERVER_KEY ?? null)
|
|
: (process.env.STACK_INTERNAL_PROJECT_SECRET_SERVER_KEY || throwErr('STACK_INTERNAL_PROJECT_SECRET_SERVER_KEY is not set')),
|
|
superSecretAdminKey: isLocalEmulator
|
|
? (process.env.STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY ?? null)
|
|
: (process.env.STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY || throwErr('STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY is not set')),
|
|
};
|
|
|
|
await globalPrismaClient.apiKeySet.upsert({
|
|
where: { projectId_id: { projectId: 'internal', id: apiKeyId } },
|
|
update: {
|
|
...keySet,
|
|
},
|
|
create: {
|
|
id: apiKeyId,
|
|
projectId: 'internal',
|
|
description: "Internal API key set",
|
|
expiresAt: new Date('2099-12-31T23:59:59Z'),
|
|
...keySet,
|
|
}
|
|
});
|
|
|
|
console.log('Updated internal API key set');
|
|
}
|
|
|
|
const shouldSeedDummyProject = process.env.STACK_SEED_ENABLE_DUMMY_PROJECT === 'true';
|
|
if (shouldSeedDummyProject) {
|
|
await seedDummyProject({
|
|
projectId: DUMMY_PROJECT_ID,
|
|
ownerTeamId: internalTeamId,
|
|
oauthProviderIds,
|
|
});
|
|
}
|
|
|
|
const developmentEnvironmentProjectData = {
|
|
display_name: 'Development Environment Project',
|
|
description: 'Seeded project for debugging development-environment dashboard behavior.',
|
|
is_production_mode: false,
|
|
is_development_environment: true,
|
|
owner_team_id: internalTeamId,
|
|
config: {
|
|
allow_localhost: true,
|
|
sign_up_enabled: true,
|
|
credential_enabled: true,
|
|
magic_link_enabled: true,
|
|
passkey_enabled: true,
|
|
client_team_creation_enabled: true,
|
|
client_user_deletion_enabled: true,
|
|
allow_user_api_keys: true,
|
|
allow_team_api_keys: true,
|
|
create_team_on_sign_up: false,
|
|
email_theme: DEFAULT_EMAIL_THEME_ID,
|
|
email_config: {
|
|
type: 'shared',
|
|
},
|
|
oauth_providers: oauthProviderIds.map((id) => ({
|
|
id: id as any,
|
|
type: 'shared',
|
|
})),
|
|
domains: [],
|
|
},
|
|
} satisfies AdminUserProjectsCrud["Admin"]["Create"];
|
|
if (await getProject(DEVELOPMENT_ENVIRONMENT_PROJECT_ID)) {
|
|
await createOrUpdateProjectWithLegacyConfig({
|
|
type: 'update',
|
|
projectId: DEVELOPMENT_ENVIRONMENT_PROJECT_ID,
|
|
branchId: DEFAULT_BRANCH_ID,
|
|
data: developmentEnvironmentProjectData,
|
|
});
|
|
} else {
|
|
await createOrUpdateProjectWithLegacyConfig({
|
|
type: 'create',
|
|
projectId: DEVELOPMENT_ENVIRONMENT_PROJECT_ID,
|
|
data: developmentEnvironmentProjectData,
|
|
});
|
|
}
|
|
|
|
// Create optional default admin user if credentials are provided.
|
|
// This user will be able to login to the dashboard with both email/password and magic link.
|
|
|
|
if ((adminEmail && adminPassword) || adminGithubId) {
|
|
const oldAdminUser = await internalPrisma.projectUser.findFirst({
|
|
where: {
|
|
mirroredProjectId: 'internal',
|
|
mirroredBranchId: DEFAULT_BRANCH_ID,
|
|
projectUserId: defaultUserId
|
|
}
|
|
});
|
|
|
|
if (oldAdminUser) {
|
|
console.log(`Admin user already exists, skipping creation`);
|
|
} else {
|
|
const newUser = await internalPrisma.projectUser.create({
|
|
data: {
|
|
displayName: 'Administrator (created by seed script)',
|
|
projectUserId: defaultUserId,
|
|
tenancyId: internalTenancy.id,
|
|
mirroredProjectId: 'internal',
|
|
mirroredBranchId: DEFAULT_BRANCH_ID,
|
|
signedUpAt: new Date(),
|
|
signUpRiskScoreBot: 0,
|
|
signUpRiskScoreFreeTrialAbuse: 0,
|
|
}
|
|
});
|
|
|
|
// Note: TeamMember creation is handled by the upsert below (after this if/else block)
|
|
// to ensure idempotency when adminInternalAccess changes between runs
|
|
|
|
if (adminEmail && adminPassword) {
|
|
await usersCrudHandlers.adminUpdate({
|
|
tenancy: internalTenancy,
|
|
user_id: defaultUserId,
|
|
data: {
|
|
password: adminPassword,
|
|
primary_email: adminEmail,
|
|
primary_email_auth_enabled: true,
|
|
},
|
|
});
|
|
|
|
console.log(`Added admin user with email ${adminEmail}`);
|
|
}
|
|
|
|
if (adminGithubId) {
|
|
const githubAccount = await internalPrisma.projectUserOAuthAccount.findFirst({
|
|
where: {
|
|
tenancyId: internalTenancy.id,
|
|
configOAuthProviderId: 'github',
|
|
providerAccountId: adminGithubId,
|
|
}
|
|
});
|
|
|
|
if (githubAccount) {
|
|
console.log(`GitHub account already exists, skipping creation`);
|
|
} else {
|
|
await internalPrisma.projectUserOAuthAccount.create({
|
|
data: {
|
|
tenancyId: internalTenancy.id,
|
|
projectUserId: newUser.projectUserId,
|
|
configOAuthProviderId: 'github',
|
|
providerAccountId: adminGithubId
|
|
}
|
|
});
|
|
|
|
await internalPrisma.authMethod.create({
|
|
data: {
|
|
tenancyId: internalTenancy.id,
|
|
projectUserId: newUser.projectUserId,
|
|
oauthAuthMethod: {
|
|
create: {
|
|
projectUserId: newUser.projectUserId,
|
|
configOAuthProviderId: 'github',
|
|
providerAccountId: adminGithubId,
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
console.log(`Added admin user with GitHub ID ${adminGithubId}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Create or ensure TeamMember exists before granting permissions.
|
|
// Using upsert here (instead of create inside the else block above) ensures
|
|
// idempotency when adminInternalAccess changes between seed runs.
|
|
if (adminInternalAccess) {
|
|
await internalPrisma.teamMember.upsert({
|
|
where: {
|
|
tenancyId_projectUserId_teamId: {
|
|
tenancyId: internalTenancy.id,
|
|
projectUserId: defaultUserId,
|
|
teamId: internalTeamId,
|
|
},
|
|
},
|
|
create: {
|
|
tenancyId: internalTenancy.id,
|
|
teamId: internalTeamId,
|
|
projectUserId: defaultUserId,
|
|
},
|
|
update: {},
|
|
});
|
|
|
|
await grantTeamPermission(internalPrisma, {
|
|
tenancy: internalTenancy,
|
|
teamId: internalTeamId,
|
|
userId: defaultUserId,
|
|
permissionId: "team_admin",
|
|
});
|
|
}
|
|
}
|
|
|
|
if (localEmulatorEnabled) {
|
|
const emulatorTeam = await internalPrisma.team.findUnique({
|
|
where: {
|
|
tenancyId_teamId: {
|
|
tenancyId: internalTenancy.id,
|
|
teamId: LOCAL_EMULATOR_OWNER_TEAM_ID,
|
|
},
|
|
},
|
|
});
|
|
if (!emulatorTeam) {
|
|
await internalPrisma.team.create({
|
|
data: {
|
|
tenancyId: internalTenancy.id,
|
|
teamId: LOCAL_EMULATOR_OWNER_TEAM_ID,
|
|
displayName: 'Emulator Team',
|
|
mirroredProjectId: "internal",
|
|
mirroredBranchId: DEFAULT_BRANCH_ID,
|
|
},
|
|
});
|
|
console.log('Created emulator team');
|
|
}
|
|
|
|
const existingUser = await internalPrisma.projectUser.findFirst({
|
|
where: {
|
|
mirroredProjectId: 'internal',
|
|
mirroredBranchId: DEFAULT_BRANCH_ID,
|
|
projectUserId: LOCAL_EMULATOR_ADMIN_USER_ID,
|
|
}
|
|
});
|
|
|
|
if (existingUser) {
|
|
console.log('Emulator user already exists, skipping creation');
|
|
} else {
|
|
await internalPrisma.projectUser.create({
|
|
data: {
|
|
displayName: 'Local Emulator User',
|
|
projectUserId: LOCAL_EMULATOR_ADMIN_USER_ID,
|
|
tenancyId: internalTenancy.id,
|
|
mirroredProjectId: 'internal',
|
|
mirroredBranchId: DEFAULT_BRANCH_ID,
|
|
signedUpAt: new Date(),
|
|
signUpRiskScoreBot: 0,
|
|
signUpRiskScoreFreeTrialAbuse: 0,
|
|
}
|
|
});
|
|
|
|
console.log('Created emulator user');
|
|
}
|
|
|
|
await internalPrisma.teamMember.upsert({
|
|
where: {
|
|
tenancyId_projectUserId_teamId: {
|
|
tenancyId: internalTenancy.id,
|
|
projectUserId: LOCAL_EMULATOR_ADMIN_USER_ID,
|
|
teamId: LOCAL_EMULATOR_OWNER_TEAM_ID,
|
|
},
|
|
},
|
|
create: {
|
|
tenancyId: internalTenancy.id,
|
|
teamId: LOCAL_EMULATOR_OWNER_TEAM_ID,
|
|
projectUserId: LOCAL_EMULATOR_ADMIN_USER_ID,
|
|
},
|
|
update: {},
|
|
});
|
|
|
|
await usersCrudHandlers.adminUpdate({
|
|
tenancy: internalTenancy,
|
|
user_id: LOCAL_EMULATOR_ADMIN_USER_ID,
|
|
data: {
|
|
password: LOCAL_EMULATOR_ADMIN_PASSWORD,
|
|
primary_email: LOCAL_EMULATOR_ADMIN_EMAIL,
|
|
primary_email_auth_enabled: true,
|
|
},
|
|
});
|
|
|
|
const userTeamMembership = await internalPrisma.teamMember.findUnique({
|
|
where: {
|
|
tenancyId_projectUserId_teamId: {
|
|
tenancyId: internalTenancy.id,
|
|
projectUserId: LOCAL_EMULATOR_ADMIN_USER_ID,
|
|
teamId: LOCAL_EMULATOR_OWNER_TEAM_ID,
|
|
},
|
|
},
|
|
select: {
|
|
projectUserId: true,
|
|
},
|
|
});
|
|
if (!userTeamMembership) {
|
|
throw new Error('Local emulator user must be a member of the local emulator owner team');
|
|
} else {
|
|
console.log('Ensured emulator user is a member of emulator team');
|
|
}
|
|
|
|
await grantTeamPermission(internalPrisma, {
|
|
tenancy: internalTenancy,
|
|
teamId: LOCAL_EMULATOR_OWNER_TEAM_ID,
|
|
userId: LOCAL_EMULATOR_ADMIN_USER_ID,
|
|
permissionId: "team_admin",
|
|
});
|
|
}
|
|
|
|
console.log('Seeding complete!');
|
|
}
|