Sign up restriction button on dashboard

Fix #66, #74
This commit is contained in:
Konstantin Wohlwend 2024-08-09 18:21:32 -07:00
parent 4792aa53f8
commit c64fbf4fcd
37 changed files with 429 additions and 124 deletions

View File

@ -9,7 +9,7 @@ jobs:
run:
runs-on: ubuntu-latest
env:
NEXT_PUBLIC_STACK_URL: http://localhost:8101
NEXT_PUBLIC_STACK_URL: http://localhost:8102
NEXT_PUBLIC_STACK_PROJECT_ID: internal
NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY: internal-project-publishable-client-key
STACK_SECRET_SERVER_KEY: internal-project-secret-server-key

View File

@ -37,3 +37,4 @@ STACK_SVIX_API_KEY=# enter the API key for the Svix webhook service here. Use `e
# Misc, optional
STACK_ACCESS_TOKEN_EXPIRATION_TIME=# enter the expiration time for the access token here. Optional, don't specify it for default value
STACK_SETUP_ADMIN_GITHUB_ID=# enter the account ID of the admin user here, and after running the seed script they will be able to access the internal project in the Stack dashboard. Optional, don't specify it for default value

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "ProjectConfig" ADD COLUMN "signUpEnabled" BOOLEAN NOT NULL DEFAULT true;

View File

@ -40,6 +40,7 @@ model ProjectConfig {
updatedAt DateTime @updatedAt
allowLocalhost Boolean
signUpEnabled Boolean @default(true)
credentialEnabled Boolean
magicLinkEnabled Boolean

View File

@ -1,72 +1,110 @@
import { PrismaClient } from '@prisma/client';
import { getEnvVariable } from '@stackframe/stack-shared/dist/utils/env';
import { throwErr } from '@stackframe/stack-shared/dist/utils/errors';
const prisma = new PrismaClient();
async function seed() {
console.log('Seeding database...');
const oldProjects = await prisma.project.findUnique({
const oldProject = await prisma.project.findUnique({
where: {
id: 'internal',
},
});
if (oldProjects) {
console.log('Internal project already exists, skipping seeding');
return;
}
await prisma.project.upsert({
where: {
id: 'internal',
},
create: {
id: 'internal',
displayName: 'Stack Dashboard',
description: 'Stack\'s admin dashboard',
isProductionMode: false,
apiKeySets: {
create: [{
description: "Internal API key set",
publishableClientKey: "this-publishable-client-key-is-for-local-development-only",
secretServerKey: "this-secret-server-key-is-for-local-development-only",
superSecretAdminKey: "this-super-secret-admin-key-is-for-local-development-only",
expiresAt: new Date('2099-12-31T23:59:59Z'),
}],
let createdProject;
if (oldProject) {
console.log('Internal project already exists, skipping its creation');
} else {
createdProject = await prisma.project.upsert({
where: {
id: 'internal',
},
config: {
create: {
allowLocalhost: true,
oauthProviderConfigs: {
create: (['github', 'facebook', 'google', 'microsoft'] as const).map((id) => ({
id,
proxiedOAuthConfig: {
create: {
type: id.toUpperCase() as any,
create: {
id: 'internal',
displayName: 'Stack Dashboard',
description: 'Stack\'s admin dashboard',
isProductionMode: false,
apiKeySets: {
create: [{
description: "Internal API key set",
publishableClientKey: "this-publishable-client-key-is-for-local-development-only",
secretServerKey: "this-secret-server-key-is-for-local-development-only",
superSecretAdminKey: "this-super-secret-admin-key-is-for-local-development-only",
expiresAt: new Date('2099-12-31T23:59:59Z'),
}],
},
config: {
create: {
allowLocalhost: true,
oauthProviderConfigs: {
create: (['github', 'facebook', 'google', 'microsoft'] as const).map((id) => ({
id,
proxiedOAuthConfig: {
create: {
type: id.toUpperCase() as any,
}
},
projectUserOAuthAccounts: {
create: []
}
})),
},
emailServiceConfig: {
create: {
proxiedEmailServiceConfig: {
create: {}
}
},
projectUserOAuthAccounts: {
create: []
}
})),
},
credentialEnabled: true,
magicLinkEnabled: true,
createTeamOnSignUp: false,
},
emailServiceConfig: {
create: {
proxiedEmailServiceConfig: {
create: {}
}
}
},
credentialEnabled: true,
magicLinkEnabled: true,
createTeamOnSignUp: false,
},
},
},
update: {},
});
console.log('Internal project created');
update: {},
});
console.log('Internal project created');
}
// eslint-disable-next-line no-restricted-syntax
const adminGithubId = process.env.STACK_SETUP_ADMIN_GITHUB_ID;
if (adminGithubId) {
console.log("Found admin GitHub ID in environment variables, creating admin user...");
await prisma.projectUser.upsert({
where: {
projectId_projectUserId: {
projectId: 'internal',
projectUserId: '707156c3-0d1b-48cf-b09d-3171c7f613d5',
},
},
create: {
projectId: 'internal',
projectUserId: '707156c3-0d1b-48cf-b09d-3171c7f613d5',
displayName: 'Admin user generated by seed script',
primaryEmailVerified: false,
authWithEmail: false,
serverMetadata: {
managedProjectIds: [
"internal",
],
},
projectUserOAuthAccounts: {
create: [{
providerAccountId: adminGithubId,
projectConfigId: createdProject?.configId ?? oldProject?.configId ?? throwErr('No internal project config ID found'),
oauthProviderConfigId: 'github',
}],
},
},
update: {},
});
console.log(`Admin user created (if it didn't already exist)`);
} else {
console.log('No admin GitHub ID found in environment variables, skipping admin user creation');
}
console.log('Seeding complete!');
}

View File

@ -247,7 +247,10 @@ export const GET = createSmartRouteHandler({
// ========================== sign up user ==========================
const newAccount = await usersCrudHandlers.serverCreate({
if (!project.config.sign_up_enabled) {
throw new KnownErrors.SignUpNotEnabled();
}
const newAccount = await usersCrudHandlers.adminCreate({
project,
data: {
display_name: userInfo.displayName,
@ -260,7 +263,7 @@ export const GET = createSmartRouteHandler({
account_id: userInfo.accountId,
email: userInfo.email,
}],
}
},
});
await storeTokens();
return {

View File

@ -45,6 +45,10 @@ export const POST = createSmartRouteHandler({
const userPrisma = usersPrisma.length > 0 ? usersPrisma[0] : null;
const isNewUser = !userPrisma;
if (isNewUser && !project.config.sign_up_enabled) {
throw new KnownErrors.SignUpNotEnabled();
}
let userObj: Pick<NonNullable<typeof userPrisma>, "projectUserId" | "displayName" | "primaryEmail"> | null = userPrisma;
if (!userObj) {
// TODO this should be in the same transaction as the read above

View File

@ -45,6 +45,10 @@ export const POST = createSmartRouteHandler({
throw passwordError;
}
if (!project.config.sign_up_enabled) {
throw new KnownErrors.SignUpNotEnabled();
}
const createdUser = await usersCrudHandlers.adminCreate({
project,
data: {

View File

@ -31,13 +31,14 @@ export const internalProjectsCrudHandlers = createLazyProxy(() => createCrudHand
id: generateUuid(),
displayName: data.display_name,
description: data.description,
isProductionMode: data.is_production_mode || false,
isProductionMode: data.is_production_mode ?? false,
config: {
create: {
credentialEnabled: data.config?.credential_enabled || true,
magicLinkEnabled: data.config?.magic_link_enabled || false,
allowLocalhost: data.config?.allow_localhost || true,
createTeamOnSignUp: data.config?.create_team_on_sign_up || false,
signUpEnabled: data.config?.sign_up_enabled ?? true,
credentialEnabled: data.config?.credential_enabled ?? true,
magicLinkEnabled: data.config?.magic_link_enabled ?? false,
allowLocalhost: data.config?.allow_localhost ?? true,
createTeamOnSignUp: data.config?.create_team_on_sign_up ?? false,
domains: data.config?.domains ? {
create: data.config.domains.map(item => ({
domain: item.domain,

View File

@ -253,6 +253,7 @@ export const projectsCrudHandlers = createLazyProxy(() => createCrudHandlers(pro
isProductionMode: data.is_production_mode,
config: {
update: {
signUpEnabled: data.config?.sign_up_enabled,
credentialEnabled: data.config?.credential_enabled,
magicLinkEnabled: data.config?.magic_link_enabled,
allowLocalhost: data.config?.allow_localhost,

View File

@ -99,6 +99,7 @@ export function projectPrismaToCrud(
config: {
id: prisma.config.id,
allow_localhost: prisma.config.allowLocalhost,
sign_up_enabled: prisma.config.signUpEnabled,
credential_enabled: prisma.config.credentialEnabled,
magic_link_enabled: prisma.config.magicLinkEnabled,
create_team_on_sign_up: prisma.config.createTeamOnSignUp,

View File

@ -40,6 +40,7 @@ model ProjectConfig {
updatedAt DateTime @updatedAt
allowLocalhost Boolean
signUpEnabled Boolean @default(true)
credentialEnabled Boolean
magicLinkEnabled Boolean

View File

@ -4,6 +4,7 @@ import { allProviders } from "@stackframe/stack-shared/dist/utils/oauth";
import { PageLayout } from "../page-layout";
import { useAdminApp } from "../use-admin-app";
import { ProviderSettingSwitch } from "./providers";
import { CardSubtitle } from "../../../../../../../../../packages/stack-ui/dist/components/ui/card";
export default function PageClient() {
const stackAdminApp = useAdminApp();
@ -12,7 +13,10 @@ export default function PageClient() {
return (
<PageLayout title="Auth Methods" description="Configure how users can sign in to your app">
<SettingCard title="Email Authentication" description="Email address based sign in.">
<SettingCard>
<CardSubtitle>
Email-based
</CardSubtitle>
<SettingSwitch
label="Email password authentication"
checked={project.config.credentialEnabled}
@ -35,9 +39,9 @@ export default function PageClient() {
});
}}
/>
</SettingCard>
<SettingCard title="OAuth Providers" description={`The "Sign in with XYZ" buttons on your app.`}>
<CardSubtitle className="mt-2">
SSO (OAuth)
</CardSubtitle>
{allProviders.map((id) => {
const provider = oauthProviders.find((provider) => provider.id === id);
return <ProviderSettingSwitch
@ -57,6 +61,20 @@ export default function PageClient() {
/>;
})}
</SettingCard>
<SettingCard title="Settings">
<SettingSwitch
label="Allow everyone to create accounts"
checked={project.config.signUpEnabled}
onCheckedChange={async (checked) => {
await project.update({
config: {
signUpEnabled: checked,
},
});
}}
hint="When disabled, only users with an existing account can sign in. You can still create new accounts manually on the dashboard."
/>
</SettingCard>
</PageLayout>
);
}

View File

@ -1,7 +1,7 @@
import PageClient from "./page-client";
export const metadata = {
title: "Auth Methods",
title: "Auth Settings",
};
export default function Page() {

View File

@ -186,7 +186,7 @@ export function ProviderSettingSwitch(props: Props) {
<div className="flex items-center gap-2">
{toTitle(props.id)}
{isShared && enabled &&
<SimpleTooltip tooltip="Shared keys are automatically created by Stack, but contain Stack's logo on the OAuth sign-in page.">
<SimpleTooltip tooltip={"Shared keys are automatically created by Stack, but show Stack's logo on the OAuth sign-in page.\n\nYou should replace these before you go into production."}>
<Badge variant="secondary">Shared keys</Badge>
</SimpleTooltip>
}

View File

@ -238,12 +238,10 @@ export default function PageClient() {
});
}}
label="Allow all localhost callbacks for development"
hint={<>
When enabled, allow access from all localhost URLs by default. This makes development easier but <b>should be disabled in production.</b>
</>}
/>
<Typography variant="secondary" type="footnote">
When enabled, allow access from all localhost URLs by default. This makes development easier but <b>should be disabled in production.</b>
</Typography>
</SettingCard>
</PageLayout>
);

View File

@ -71,12 +71,10 @@ export function DataTableToolbar<TData>({
const rowModel = table.getCoreRowModel();
const rows = rowModel.rows.map(row => Object.fromEntries(row.getAllCells().map(c => [c.column.id, renderCellValue(c)]).filter(([_, v]) => v !== undefined)));
console.log(table.getAllColumns());
if (rows.length === 0) {
alert("No data to export");
return;
}
console.log(rows);
const csv = generateCsv(csvConfig)(rows as any);
download(csvConfig)(csv);
}}

View File

@ -2,7 +2,7 @@
import { useRouter } from "@/components/router";
import { useFromNow } from '@/hooks/use-from-now';
import { AdminProject } from '@stackframe/stack';
import { CardDescription, CardFooter, CardHeader, CardTitle, ClickableCard, Typography } from '@stackframe/stack-ui';
import { CardContent, CardDescription, CardFooter, CardHeader, CardTitle, ClickableCard, Typography } from '@stackframe/stack-ui';
export function ProjectCard({ project }: { project: AdminProject }) {
const createdAt = useFromNow(project.createdAt);
@ -14,7 +14,7 @@ export function ProjectCard({ project }: { project: AdminProject }) {
<CardTitle className="normal-case">{project.displayName}</CardTitle>
<CardDescription>{project.description}</CardDescription>
</CardHeader>
<CardFooter className="flex justify-between">
<CardFooter className="flex justify-between mt-2">
<Typography type='label' variant='secondary'>
{project.userCount} users
</Typography>

View File

@ -1,6 +1,6 @@
import { yupResolver } from "@hookform/resolvers/yup";
import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, Button, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, DelayedInput, Form, Label, Switch, useToast } from "@stackframe/stack-ui";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, Button, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, DelayedInput, Form, Label, Switch, Typography, useToast } from "@stackframe/stack-ui";
import { Settings } from "lucide-react";
import React, { useEffect, useId, useState } from "react";
import { FieldValues, useForm } from "react-hook-form";
@ -8,7 +8,7 @@ import * as yup from "yup";
export function SettingCard(props: {
title: string,
title?: string,
description?: string,
actions?: React.ReactNode,
children?: React.ReactNode,
@ -16,10 +16,12 @@ export function SettingCard(props: {
}) {
return (
<Card>
<CardHeader>
<CardTitle>{props.title}</CardTitle>
{props.description && <CardDescription>{props.description}</CardDescription>}
</CardHeader>
{(props.title || props.description) && (
<CardHeader>
{props.title && <CardTitle>{props.title}</CardTitle>}
{props.description && <CardDescription>{props.description}</CardDescription>}
</CardHeader>
)}
<CardContent className="flex flex-col gap-4">
{props.accordion ?
@ -45,6 +47,7 @@ export function SettingCard(props: {
export function SettingSwitch(props: {
label: string | React.ReactNode,
hint?: string | React.ReactNode,
checked?: boolean,
disabled?: boolean,
onCheckedChange: (checked: boolean) => void | Promise<void>,
@ -62,15 +65,18 @@ export function SettingSwitch(props: {
};
return (
<div className="flex items-center">
<Switch
id={id}
checked={checked}
onCheckedChange={onCheckedChange}
disabled={props.disabled}
/>
<Label className='px-2' htmlFor={id}>{props.label}</Label>
{showActions && props.actions}
<div className="flex flex-col gap-2">
<div className="flex items-center">
<Switch
id={id}
checked={checked}
onCheckedChange={onCheckedChange}
disabled={props.disabled}
/>
<Label className='px-2' htmlFor={id}>{props.label}</Label>
{showActions && props.actions}
</div>
{props.hint && <Typography variant="secondary" type="footnote">{props.hint}</Typography>}
</div>
);
}

View File

@ -131,7 +131,7 @@ async function parseAuth(req: NextRequest): Promise<SmartRequestAuth | null> {
if (!projectId) throw new KnownErrors.RequestTypeWithoutProjectId(requestType);
let projectAccessType: "key" | "internal-user-token";
if (adminAccessToken) {
if (adminAccessToken !== null) {
const reason = await whyNotProjectAdmin(projectId, adminAccessToken);
switch (reason) {
case null: {

View File

@ -1,7 +1,7 @@
import { InternalProjectsCrud } from "@stackframe/stack-shared/dist/interface/crud/projects";
import { generateSecureRandomString } from "@stackframe/stack-shared/dist/utils/crypto";
import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { filterUndefined } from "@stackframe/stack-shared/dist/utils/objects";
import { camelCaseToSnakeCase } from "@stackframe/stack-shared/dist/utils/strings";
import { expect } from "vitest";
import { Context, Mailbox, NiceRequestInit, NiceResponse, STACK_BACKEND_BASE_URL, STACK_INTERNAL_PROJECT_ADMIN_KEY, STACK_INTERNAL_PROJECT_CLIENT_KEY, STACK_INTERNAL_PROJECT_ID, STACK_INTERNAL_PROJECT_SERVER_KEY, createMailbox, localRedirectUrl, niceFetch, updateCookiesFromResponse } from "../helpers";
@ -31,6 +31,7 @@ export type ProjectKeys = "no-project" | {
publishableClientKey?: string,
secretServerKey?: string,
superSecretAdminKey?: string,
adminAccessToken?: string,
};
export const InternalProjectKeys = {
@ -85,6 +86,7 @@ export async function niceBackendFetch(url: string | URL, options?: Omit<NiceReq
"x-stack-publishable-client-key": projectKeys.publishableClientKey,
"x-stack-secret-server-key": projectKeys.secretServerKey,
"x-stack-super-secret-admin-key": projectKeys.superSecretAdminKey,
'x-stack-admin-access-token': projectKeys.adminAccessToken,
} : {},
"x-stack-access-token": userAuth?.accessToken,
"x-stack-refresh-token": userAuth?.refreshToken,
@ -185,7 +187,7 @@ export namespace Auth {
const mailbox = backendContext.value.mailbox;
const sendSignInCodeRes = await sendSignInCode();
const messages = await mailbox.fetchMessages();
const message = messages.findLast((message) => message.subject === "Sign in to Stack Dashboard") ?? throwErr("Sign-in code message not found");
const message = messages.findLast((message) => message.subject.includes("Sign in to")) ?? throwErr("Sign-in code message not found");
const signInCode = message.body?.text.match(/http:\/\/localhost:12345\/some-callback-url\?code=([a-zA-Z0-9]+)/)?.[1] ?? throwErr("Sign-in URL not found");
const response = await niceBackendFetch("/api/v1/auth/otp/sign-in", {
method: "POST",
@ -582,8 +584,8 @@ export namespace ApiKey {
};
}
export async function createAndSetProjectKeys(adminAccessToken: string, body?: any) {
const res = await ApiKey.create(adminAccessToken, body);
export async function createAndSetProjectKeys(adminAccessToken?: string, body?: any) {
const res = await ApiKey.create(adminAccessToken ?? (backendContext.value.projectKeys !== "no-project" && backendContext.value.projectKeys.adminAccessToken || throwErr("Missing adminAccessToken")), body);
backendContext.set({ projectKeys: res.projectKeys });
return res;
}
@ -599,13 +601,19 @@ export namespace Project {
...body,
},
});
expect(response).toMatchObject({
status: 201,
body: {
id: expect.any(String),
},
});
return {
createProjectResponse: response,
projectId: response.body.id,
projectId: response.body.id as string,
};
}
export async function updateCurrent(adminAccessToken: string, body: any) {
export async function updateCurrent(adminAccessToken: string, body: Partial<InternalProjectsCrud["Admin"]["Create"]>) {
const response = await niceBackendFetch(`/api/v1/projects/current`, {
accessType: "admin",
method: "PATCH",
@ -620,19 +628,19 @@ export namespace Project {
};
}
export async function createAndSetAdmin(body?: any) {
export async function createAndGetAdminToken(body?: Partial<InternalProjectsCrud["Admin"]["Create"]>) {
backendContext.set({
projectKeys: InternalProjectKeys,
});
await Auth.Otp.signIn();
const { projectId, createProjectResponse } = await Project.create(body);
const adminAccessToken = backendContext.value.userAuth?.accessToken;
expect(adminAccessToken).toBeDefined();
const { projectId, createProjectResponse } = await Project.create(body);
const createResult = await Project.create(body);
backendContext.set({
projectKeys: {
projectId,
projectId: createResult.projectId,
},
userAuth: null,
});
@ -643,6 +651,17 @@ export namespace Project {
createProjectResponse,
};
}
export async function createAndSwitch(body?: Partial<InternalProjectsCrud["Admin"]["Create"]>) {
const createResult = await Project.createAndGetAdminToken(body);
backendContext.set({
projectKeys: {
projectId: createResult.projectId,
adminAccessToken: createResult.adminAccessToken,
},
});
return createResult;
}
}
export namespace Team {

View File

@ -1,6 +1,6 @@
import { it, updateCookiesFromResponse } from "../../../../../../helpers";
import { Auth, niceBackendFetch } from "../../../../../backend-helpers";
import { ApiKey, Auth, Project, niceBackendFetch } from "../../../../../backend-helpers";
it("should return outer authorization code when inner callback url is valid", async ({ expect }) => {
const response = await Auth.OAuth.getAuthorizationCode();
@ -34,6 +34,33 @@ it("should fail when inner callback has invalid provider ID", async ({ expect })
`);
});
it("should fail when account is new and sign ups are disabled", async ({ expect }) => {
await Project.createAndSwitch({ config: { sign_up_enabled: false, oauth_providers: [ { id: "facebook", type: "shared", enabled: true } ] } });
await ApiKey.createAndSetProjectKeys();
const getInnerCallbackUrlResponse = await Auth.OAuth.getInnerCallbackUrl();
const cookie = updateCookiesFromResponse("", getInnerCallbackUrlResponse.authorizeResponse);
const response = await niceBackendFetch(getInnerCallbackUrlResponse.innerCallbackUrl, {
redirect: "manual",
headers: {
cookie,
},
});
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": {
"code": "SIGN_UP_NOT_ENABLED",
"error": "Creation of new accounts is not enabled for this project. Please ask the project owner to enable it.",
},
"headers": Headers {
"set-cookie": <deleting cookie 'stack-oauth-inner-<stripped cookie name key>' at path '/'>,
"x-stack-known-error": "SIGN_UP_NOT_ENABLED",
<some fields may have been hidden>,
},
}
`);
});
it("should fail when cookies are missing", async ({ expect }) => {
const getInnerCallbackUrlResponse = await Auth.OAuth.getInnerCallbackUrl();
const response = await niceBackendFetch(getInnerCallbackUrlResponse.innerCallbackUrl, {

View File

@ -1,5 +1,5 @@
import { it } from "../../../../../../helpers";
import { backendContext, Auth, niceBackendFetch } from "../../../../../backend-helpers";
import { Auth, Project, backendContext, niceBackendFetch } from "../../../../../backend-helpers";
it("should send a sign-in code per e-mail", async ({ expect }) => {
await Auth.Otp.sendSignInCode();
@ -40,6 +40,52 @@ it('should refuse to send a sign-in code if the redirect URL is invalid', async
`);
});
it("should refuse to sign up a new user if magic links are disabled on the project", async ({ expect }) => {
await Project.createAndSwitch({ config: { magic_link_enabled: false } });
const mailbox = backendContext.value.mailbox;
const response = await niceBackendFetch("/api/v1/auth/otp/send-sign-in-code", {
method: "POST",
accessType: "client",
body: {
email: mailbox.emailAddress,
callback_url: "http://localhost:12345/some-callback-url",
},
});
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 403,
"body": "Magic link is not enabled for this project",
"headers": Headers { <some fields may have been hidden> },
}
`);
});
it("should refuse to sign up a new user if sign ups are disabled on the project", async ({ expect }) => {
await Project.createAndSwitch({ config: { sign_up_enabled: false, credential_enabled: false, magic_link_enabled: true } });
const mailbox = backendContext.value.mailbox;
const response = await niceBackendFetch("/api/v1/auth/otp/send-sign-in-code", {
method: "POST",
accessType: "client",
body: {
email: mailbox.emailAddress,
callback_url: "http://localhost:12345/some-callback-url",
},
});
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": {
"code": "SIGN_UP_NOT_ENABLED",
"error": "Creation of new accounts is not enabled for this project. Please ask the project owner to enable it.",
},
"headers": Headers {
"x-stack-known-error": "SIGN_UP_NOT_ENABLED",
<some fields may have been hidden>,
},
}
`);
});
it.todo("should create a team for newly created users if configured as such");
it.todo("should not create a team for newly created users if not configured as such");

View File

@ -1,5 +1,5 @@
import { it } from "../../../../../../helpers";
import { Auth, backendContext, niceBackendFetch } from "../../../../../backend-helpers";
import { Auth, Project, backendContext, niceBackendFetch } from "../../../../../backend-helpers";
it("should sign up new users and sign in existing users", async ({ expect }) => {
const res1 = await Auth.Otp.signIn();
@ -39,6 +39,33 @@ it("should sign in users created with the server API", async ({ expect }) => {
primary_email_auth_enabled: true,
},
});
expect(response.status).toBe(201);
const res2 = await Auth.Otp.signIn();
expect(res2.signInResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": {
"access_token": <stripped field 'access_token'>,
"is_new_user": false,
"refresh_token": <stripped field 'refresh_token'>,
"user_id": "<stripped UUID>",
},
"headers": Headers { <some fields may have been hidden> },
}
`);
});
it("should sign in users created with the server API even if sign up is disabled", async ({ expect }) => {
await Project.createAndSwitch({ config: { sign_up_enabled: false, magic_link_enabled: true } });
const response = await niceBackendFetch("/api/v1/users", {
accessType: "server",
method: "POST",
body: {
primary_email: backendContext.value.mailbox.emailAddress,
primary_email_auth_enabled: true,
},
});
expect(response.status).toBe(201);
const res2 = await Auth.Otp.signIn();
expect(res2.signInResponse).toMatchInlineSnapshot(`
NiceResponse {

View File

@ -1,6 +1,6 @@
import { generateSecureRandomString } from "@stackframe/stack-shared/dist/utils/crypto";
import { it } from "../../../../../../helpers";
import { Auth, backendContext, niceBackendFetch } from "../../../../../backend-helpers";
import { Auth, Project, backendContext, niceBackendFetch } from "../../../../../backend-helpers";
it("should sign up new users", async ({ expect }) => {
const res = await Auth.Password.signUpWithEmail();
@ -70,6 +70,58 @@ it("should not allow signing up with an e-mail that already exists", async ({ ex
`);
});
it("should not allow signing up if credentials are disabled", async ({ expect }) => {
await Project.createAndSwitch({ config: { credential_enabled: false } });
const res2 = await niceBackendFetch("/api/v1/auth/password/sign-up", {
method: "POST",
accessType: "client",
body: {
email: backendContext.value.mailbox.emailAddress,
password: generateSecureRandomString(),
verification_callback_url: "http://localhost:12345",
},
});
expect(res2).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": {
"code": "PASSWORD_AUTHENTICATION_NOT_ENABLED",
"error": "Password authentication is not enabled for this project.",
},
"headers": Headers {
"x-stack-known-error": "PASSWORD_AUTHENTICATION_NOT_ENABLED",
<some fields may have been hidden>,
},
}
`);
});
it("should not allow signing up if sign ups are disabled", async ({ expect }) => {
await Project.createAndSwitch({ config: { sign_up_enabled: false, credential_enabled: true } });
const res2 = await niceBackendFetch("/api/v1/auth/password/sign-up", {
method: "POST",
accessType: "client",
body: {
email: backendContext.value.mailbox.emailAddress,
password: generateSecureRandomString(),
verification_callback_url: "http://localhost:12345",
},
});
expect(res2).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": {
"code": "SIGN_UP_NOT_ENABLED",
"error": "Creation of new accounts is not enabled for this project. Please ask the project owner to enable it.",
},
"headers": Headers {
"x-stack-known-error": "SIGN_UP_NOT_ENABLED",
<some fields may have been hidden>,
},
}
`);
});
it("cannot use empty password to sign up", async ({ expect }) => {
const res = await niceBackendFetch("/api/v1/auth/password/sign-up", {
method: "POST",

View File

@ -126,7 +126,7 @@ describe("with admin access to a non-internal project", () => {
});
it("creates, list, updates, revokes api keys", async ({ expect }) => {
const { adminAccessToken } = await Project.createAndSetAdmin();
const { adminAccessToken } = await Project.createAndGetAdminToken();
const { createApiKeyResponse: response1 } = await ApiKey.create(adminAccessToken);
expect(response1).toMatchInlineSnapshot(`
NiceResponse {

View File

@ -57,7 +57,7 @@ it("lists all current projects (empty list)", async ({ expect }) => {
it("creates a new project", async ({ expect }) => {
backendContext.set({ projectKeys: InternalProjectClientKeys });
await Auth.Otp.signIn();
const result = await Project.createAndSetAdmin({
const result = await Project.createAndGetAdminToken({
display_name: "Test Project",
});
expect(result.createProjectResponse).toMatchInlineSnapshot(`
@ -74,6 +74,7 @@ it("creates a new project", async ({ expect }) => {
"id": "<stripped UUID>",
"magic_link_enabled": false,
"oauth_providers": [],
"sign_up_enabled": true,
"team_creator_default_permissions": [{ "id": "admin" }],
"team_member_default_permissions": [{ "id": "member" }],
},
@ -98,6 +99,7 @@ it("creates a new project with different configurations", async ({ expect }) =>
is_production_mode: true,
config: {
allow_localhost: false,
sign_up_enabled: false,
credential_enabled: false,
magic_link_enabled: true,
},
@ -107,15 +109,16 @@ it("creates a new project with different configurations", async ({ expect }) =>
"status": 201,
"body": {
"config": {
"allow_localhost": true,
"allow_localhost": false,
"create_team_on_sign_up": false,
"credential_enabled": true,
"credential_enabled": false,
"domains": [],
"email_config": { "type": "shared" },
"enabled_oauth_providers": [],
"id": "<stripped UUID>",
"magic_link_enabled": true,
"oauth_providers": [],
"sign_up_enabled": false,
"team_creator_default_permissions": [{ "id": "admin" }],
"team_member_default_permissions": [{ "id": "member" }],
},
@ -177,6 +180,7 @@ it("creates a new project with different configurations", async ({ expect }) =>
"type": "shared",
},
],
"sign_up_enabled": true,
"team_creator_default_permissions": [{ "id": "admin" }],
"team_member_default_permissions": [{ "id": "member" }],
},
@ -214,6 +218,7 @@ it("creates a new project with different configurations", async ({ expect }) =>
"id": "<stripped UUID>",
"magic_link_enabled": false,
"oauth_providers": [],
"sign_up_enabled": true,
"team_creator_default_permissions": [{ "id": "admin" }],
"team_member_default_permissions": [{ "id": "member" }],
},
@ -265,6 +270,7 @@ it("creates a new project with different configurations", async ({ expect }) =>
"id": "<stripped UUID>",
"magic_link_enabled": false,
"oauth_providers": [],
"sign_up_enabled": true,
"team_creator_default_permissions": [{ "id": "admin" }],
"team_member_default_permissions": [{ "id": "member" }],
},
@ -318,6 +324,7 @@ it("creates a new project with different configurations", async ({ expect }) =>
"id": "<stripped UUID>",
"magic_link_enabled": false,
"oauth_providers": [],
"sign_up_enabled": true,
"team_creator_default_permissions": [{ "id": "admin" }],
"team_member_default_permissions": [{ "id": "member" }],
},
@ -354,6 +361,7 @@ it("lists the current projects after creating a new project", async ({ expect })
"id": "<stripped UUID>",
"magic_link_enabled": false,
"oauth_providers": [],
"sign_up_enabled": true,
"team_creator_default_permissions": [{ "id": "admin" }],
"team_member_default_permissions": [{ "id": "member" }],
},

View File

@ -41,6 +41,7 @@ it("gets current project (internal)", async ({ expect }) => {
{ "id": "microsoft" },
],
"magic_link_enabled": true,
"sign_up_enabled": true,
},
"display_name": "Stack Dashboard",
"id": "internal",
@ -52,7 +53,7 @@ it("gets current project (internal)", async ({ expect }) => {
it("creates and updates the basic project information of a project", async ({ expect }) => {
await Auth.Otp.signIn();
const { adminAccessToken } = await Project.createAndSetAdmin();
const { adminAccessToken } = await Project.createAndGetAdminToken();
const { updateProjectResponse: response } = await Project.updateCurrent(adminAccessToken, {
display_name: "Updated Project",
description: "Updated description",
@ -72,6 +73,7 @@ it("creates and updates the basic project information of a project", async ({ ex
"id": "<stripped UUID>",
"magic_link_enabled": false,
"oauth_providers": [],
"sign_up_enabled": true,
"team_creator_default_permissions": [{ "id": "admin" }],
"team_member_default_permissions": [{ "id": "member" }],
},
@ -89,10 +91,11 @@ it("creates and updates the basic project information of a project", async ({ ex
it("updates the basic project configuration", async ({ expect }) => {
await Auth.Otp.signIn();
const { adminAccessToken } = await Project.createAndSetAdmin();
const { adminAccessToken } = await Project.createAndGetAdminToken();
const { updateProjectResponse: response } = await Project.updateCurrent(adminAccessToken, {
config: {
allow_localhost: false,
sign_up_enabled: false,
credential_enabled: false,
magic_link_enabled: true,
},
@ -111,6 +114,7 @@ it("updates the basic project configuration", async ({ expect }) => {
"id": "<stripped UUID>",
"magic_link_enabled": true,
"oauth_providers": [],
"sign_up_enabled": false,
"team_creator_default_permissions": [{ "id": "admin" }],
"team_member_default_permissions": [{ "id": "member" }],
},
@ -128,7 +132,7 @@ it("updates the basic project configuration", async ({ expect }) => {
it("updates the project domains configuration", async ({ expect }) => {
await Auth.Otp.signIn();
const { adminAccessToken } = await Project.createAndSetAdmin();
const { adminAccessToken } = await Project.createAndGetAdminToken();
const { updateProjectResponse: response1 } = await Project.updateCurrent(adminAccessToken, {
config: {
domains: [{
@ -156,6 +160,7 @@ it("updates the project domains configuration", async ({ expect }) => {
"id": "<stripped UUID>",
"magic_link_enabled": false,
"oauth_providers": [],
"sign_up_enabled": true,
"team_creator_default_permissions": [{ "id": "admin" }],
"team_member_default_permissions": [{ "id": "member" }],
},
@ -207,6 +212,7 @@ it("updates the project domains configuration", async ({ expect }) => {
"id": "<stripped UUID>",
"magic_link_enabled": false,
"oauth_providers": [],
"sign_up_enabled": true,
"team_creator_default_permissions": [{ "id": "admin" }],
"team_member_default_permissions": [{ "id": "member" }],
},
@ -224,7 +230,7 @@ it("updates the project domains configuration", async ({ expect }) => {
it("updates the project email configuration", async ({ expect }) => {
await Auth.Otp.signIn();
const { adminAccessToken } = await Project.createAndSetAdmin();
const { adminAccessToken } = await Project.createAndGetAdminToken();
const { updateProjectResponse: response1 } = await Project.updateCurrent(adminAccessToken, {
config: {
email_config: {
@ -260,6 +266,7 @@ it("updates the project email configuration", async ({ expect }) => {
"id": "<stripped UUID>",
"magic_link_enabled": false,
"oauth_providers": [],
"sign_up_enabled": true,
"team_creator_default_permissions": [{ "id": "admin" }],
"team_member_default_permissions": [{ "id": "member" }],
},
@ -310,6 +317,7 @@ it("updates the project email configuration", async ({ expect }) => {
"id": "<stripped UUID>",
"magic_link_enabled": false,
"oauth_providers": [],
"sign_up_enabled": true,
"team_creator_default_permissions": [{ "id": "admin" }],
"team_member_default_permissions": [{ "id": "member" }],
},
@ -346,6 +354,7 @@ it("updates the project email configuration", async ({ expect }) => {
"id": "<stripped UUID>",
"magic_link_enabled": false,
"oauth_providers": [],
"sign_up_enabled": true,
"team_creator_default_permissions": [{ "id": "admin" }],
"team_member_default_permissions": [{ "id": "member" }],
},
@ -382,6 +391,7 @@ it("updates the project email configuration", async ({ expect }) => {
"id": "<stripped UUID>",
"magic_link_enabled": false,
"oauth_providers": [],
"sign_up_enabled": true,
"team_creator_default_permissions": [{ "id": "admin" }],
"team_member_default_permissions": [{ "id": "member" }],
},
@ -432,6 +442,7 @@ it("updates the project email configuration", async ({ expect }) => {
"id": "<stripped UUID>",
"magic_link_enabled": false,
"oauth_providers": [],
"sign_up_enabled": true,
"team_creator_default_permissions": [{ "id": "admin" }],
"team_member_default_permissions": [{ "id": "member" }],
},
@ -449,7 +460,7 @@ it("updates the project email configuration", async ({ expect }) => {
it("updates the project email configuration with the wrong parameters", async ({ expect }) => {
await Auth.Otp.signIn();
const { adminAccessToken } = await Project.createAndSetAdmin();
const { adminAccessToken } = await Project.createAndGetAdminToken();
const { updateProjectResponse: response1 } = await Project.updateCurrent(adminAccessToken, {
config: {
email_config: {
@ -500,7 +511,7 @@ it("updates the project email configuration with the wrong parameters", async ({
it("updates the project oauth configuration", async ({ expect }) => {
await Auth.Otp.signIn();
const { adminAccessToken } = await Project.createAndSetAdmin();
const { adminAccessToken } = await Project.createAndGetAdminToken();
// create google oauth provider with shared type
const { updateProjectResponse: response1 } = await Project.updateCurrent(adminAccessToken, {
config: {
@ -531,6 +542,7 @@ it("updates the project oauth configuration", async ({ expect }) => {
"type": "shared",
},
],
"sign_up_enabled": true,
"team_creator_default_permissions": [{ "id": "admin" }],
"team_member_default_permissions": [{ "id": "member" }],
},
@ -575,6 +587,7 @@ it("updates the project oauth configuration", async ({ expect }) => {
"type": "shared",
},
],
"sign_up_enabled": true,
"team_creator_default_permissions": [{ "id": "admin" }],
"team_member_default_permissions": [{ "id": "member" }],
},
@ -623,6 +636,7 @@ it("updates the project oauth configuration", async ({ expect }) => {
"type": "standard",
},
],
"sign_up_enabled": true,
"team_creator_default_permissions": [{ "id": "admin" }],
"team_member_default_permissions": [{ "id": "member" }],
},
@ -700,6 +714,7 @@ it("updates the project oauth configuration", async ({ expect }) => {
"type": "shared",
},
],
"sign_up_enabled": true,
"team_creator_default_permissions": [{ "id": "admin" }],
"team_member_default_permissions": [{ "id": "member" }],
},
@ -756,6 +771,7 @@ it("updates the project oauth configuration", async ({ expect }) => {
"type": "shared",
},
],
"sign_up_enabled": true,
"team_creator_default_permissions": [{ "id": "admin" }],
"team_member_default_permissions": [{ "id": "member" }],
},

View File

@ -178,7 +178,7 @@ it("creates a team and manage users on the server", async ({ expect }) => {
it("should give team creator default permissions", async ({ expect }) => {
backendContext.set({ projectKeys: InternalProjectKeys });
const { adminAccessToken } = await Project.createAndSetAdmin();
const { adminAccessToken } = await Project.createAndGetAdminToken();
await ApiKey.createAndSetProjectKeys(adminAccessToken);
const { userId: userId1 } = await Auth.Password.signUpWithEmail({ password: 'test1234' });

View File

@ -6,7 +6,7 @@ it("lists all the team permissions", async ({ expect }) => {
backendContext.set({
projectKeys: InternalProjectKeys,
});
const { adminAccessToken } = await Project.createAndSetAdmin();
const { adminAccessToken } = await Project.createAndGetAdminToken();
const response = await niceBackendFetch(`/api/v1/team-permission-definitions`, {
accessType: "admin",
@ -74,7 +74,7 @@ it("lists all the team permissions", async ({ expect }) => {
it("creates, updates, and delete a new team permission", async ({ expect }) => {
backendContext.set({ projectKeys: InternalProjectKeys });
const { adminAccessToken } = await Project.createAndSetAdmin();
const { adminAccessToken } = await Project.createAndGetAdminToken();
const response1 = await niceBackendFetch(`/api/v1/team-permission-definitions`, {
accessType: "admin",

View File

@ -45,7 +45,7 @@ it("is not allowed to grant non-existing permission to a user on the server", as
it("can create a new permission and grant it to a user on the server", async ({ expect }) => {
backendContext.set({ projectKeys: InternalProjectKeys });
const { adminAccessToken } = await Project.createAndSetAdmin();
const { adminAccessToken } = await Project.createAndGetAdminToken();
// create a permission child
await niceBackendFetch(`/api/v1/team-permission-definitions`, {
@ -149,7 +149,7 @@ it("can create a new permission and grant it to a user on the server", async ({
it("can customize default team permissions", async ({ expect }) => {
await Auth.Otp.signIn();
const { adminAccessToken } = await Project.createAndSetAdmin();
const { adminAccessToken } = await Project.createAndGetAdminToken();
const response1 = await niceBackendFetch(`/api/v1/team-permission-definitions`, {
accessType: "admin",
@ -194,6 +194,7 @@ it("can customize default team permissions", async ({ expect }) => {
"id": "<stripped UUID>",
"magic_link_enabled": false,
"oauth_providers": [],
"sign_up_enabled": true,
"team_creator_default_permissions": [{ "id": "admin" }],
"team_member_default_permissions": [{ "id": "test" }],
},

View File

@ -46,6 +46,7 @@ export const projectsCrudServerReadSchema = yupObject({
config: yupObject({
id: schemaFields.projectConfigIdSchema.required(),
allow_localhost: schemaFields.projectAllowLocalhostSchema.required(),
sign_up_enabled: schemaFields.projectSignUpEnabledSchema.required(),
credential_enabled: schemaFields.projectCredentialEnabledSchema.required(),
magic_link_enabled: schemaFields.projectMagicLinkEnabledSchema.required(),
oauth_providers: yupArray(oauthProviderSchema.required()).required(),
@ -62,6 +63,7 @@ export const projectsCrudClientReadSchema = yupObject({
id: schemaFields.projectIdSchema.required(),
display_name: schemaFields.projectDisplayNameSchema.required(),
config: yupObject({
sign_up_enabled: schemaFields.projectSignUpEnabledSchema.required(),
credential_enabled: schemaFields.projectCredentialEnabledSchema.required(),
magic_link_enabled: schemaFields.projectMagicLinkEnabledSchema.required(),
enabled_oauth_providers: yupArray(enabledOAuthProviderSchema.required()).required(),
@ -74,6 +76,7 @@ export const projectsCrudServerUpdateSchema = yupObject({
description: schemaFields.projectDescriptionSchema.optional(),
is_production_mode: schemaFields.projectIsProductionModeSchema.optional(),
config: yupObject({
sign_up_enabled: schemaFields.projectSignUpEnabledSchema.optional(),
credential_enabled: schemaFields.projectCredentialEnabledSchema.optional(),
magic_link_enabled: schemaFields.projectMagicLinkEnabledSchema.optional(),
allow_localhost: schemaFields.projectAllowLocalhostSchema.optional(),

View File

@ -661,6 +661,16 @@ const ProjectNotFound = createKnownErrorConstructor(
(json: any) => [json.project_id] as const,
);
const SignUpNotEnabled = createKnownErrorConstructor(
KnownError,
"SIGN_UP_NOT_ENABLED",
() => [
400,
"Creation of new accounts is not enabled for this project. Please ask the project owner to enable it.",
] as const,
() => [] as const,
);
const PasswordAuthenticationNotEnabled = createKnownErrorConstructor(
KnownError,
"PASSWORD_AUTHENTICATION_NOT_ENABLED",
@ -1135,6 +1145,7 @@ export const KnownErrors = {
UserNotFound,
ApiKeyNotFound,
ProjectNotFound,
SignUpNotEnabled,
PasswordAuthenticationNotEnabled,
EmailPasswordMismatch,
RedirectUrlNotWhitelisted,

View File

@ -148,6 +148,7 @@ export const projectConfigIdSchema = yupString().meta({ openapiField: { descript
export const projectAllowLocalhostSchema = yupBoolean().meta({ openapiField: { description: 'Whether localhost is allowed as a domain for this project. Should only be allowed in development mode', exampleValue: true } });
export const projectCreateTeamOnSignUpSchema = yupBoolean().meta({ openapiField: { description: 'Whether a team should be created for each user that signs up', exampleValue: true } });
export const projectMagicLinkEnabledSchema = yupBoolean().meta({ openapiField: { description: 'Whether magic link authentication is enabled for this project', exampleValue: true } });
export const projectSignUpEnabledSchema = yupBoolean().meta({ openapiField: { description: 'Whether users can sign up new accounts, or whether they are only allowed to sign in to existing accounts. Regardless of this option, the server API can always create new users with the `POST /users` endpoint.', exampleValue: true } });
export const projectCredentialEnabledSchema = yupBoolean().meta({ openapiField: { description: 'Whether email password authentication is enabled for this project', exampleValue: true } });
// Project OAuth config
export const oauthIdSchema = yupString().oneOf(allProviders).meta({ openapiField: { description: `OAuth provider ID, one of ${allProviders.map(x => `\`${x}\``).join(', ')}`, exampleValue: 'google' } });

View File

@ -21,7 +21,7 @@ export function SimpleTooltip(props: {
</div>
</TooltipTrigger>
<TooltipContent>
<div className="max-w-60 text-center text-wrap">
<div className="max-w-60 text-center text-wrap whitespace-pre-wrap">
{props.tooltip}
</div>
</TooltipContent>

View File

@ -38,7 +38,7 @@ const CardHeader = React.forwardRef<
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
className={cn("flex flex-col space-y-1.5 p-6 pb-0", className)}
{...props}
/>
));
@ -72,10 +72,21 @@ const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
<div ref={ref} className={cn("p-6", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardSubtitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<h4
ref={ref}
className={cn("text-sm text-muted-foreground font-bold", className)}
{...props}
/>
));
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
@ -88,4 +99,4 @@ const CardFooter = React.forwardRef<
));
CardFooter.displayName = "CardFooter";
export { Card, ClickableCard, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
export { Card, ClickableCard, CardHeader, CardFooter, CardTitle, CardDescription, CardContent, CardSubtitle };

View File

@ -675,6 +675,7 @@ class _StackClientAppImpl<HasTokenStore extends boolean, ProjectId extends strin
return {
id: crud.id,
config: {
signUpEnabled: crud.config.sign_up_enabled,
credentialEnabled: crud.config.credential_enabled,
magicLinkEnabled: crud.config.magic_link_enabled,
oauthProviders: crud.config.enabled_oauth_providers.map((p) => ({
@ -1788,6 +1789,7 @@ class _StackAdminAppImpl<HasTokenStore extends boolean, ProjectId extends string
isProductionMode: data.is_production_mode,
config: {
id: data.config.id,
signUpEnabled: data.config.sign_up_enabled,
credentialEnabled: data.config.credential_enabled,
magicLinkEnabled: data.config.magic_link_enabled,
allowLocalhost: data.config.allow_localhost,
@ -2324,6 +2326,7 @@ function adminProjectUpdateOptionsToCrud(options: AdminProjectUpdateOptions): Pr
sender_email: options.config.emailConfig.senderEmail,
}
),
sign_up_enabled: options.config?.signUpEnabled,
credential_enabled: options.config?.credentialEnabled,
magic_link_enabled: options.config?.magicLinkEnabled,
allow_localhost: options.config?.allowLocalhost,
@ -2347,6 +2350,7 @@ function adminProjectCreateOptionsToCrud(options: AdminProjectCreateOptions): In
type _______________PROJECT_CONFIG_______________ = never; // this is a marker for VSCode's outline view
export type ProjectConfig = {
readonly signUpEnabled: boolean,
readonly credentialEnabled: boolean,
readonly magicLinkEnabled: boolean,
readonly oauthProviders: OAuthProviderConfig[],
@ -2357,6 +2361,7 @@ export type OAuthProviderConfig = {
};
export type AdminProjectConfig = {
readonly signUpEnabled: boolean,
readonly credentialEnabled: boolean,
readonly magicLinkEnabled: boolean,
readonly allowLocalhost: boolean,
@ -2407,6 +2412,7 @@ export type AdminProjectConfigUpdateOptions = {
handlerPath: string,
}[],
oauthProviders?: AdminOAuthProviderConfig[],
signUpEnabled?: boolean,
credentialEnabled?: boolean,
magicLinkEnabled?: boolean,
allowLocalhost?: boolean,