From c64fbf4fcd52cd14c1674018b222b8f24554823f Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Fri, 9 Aug 2024 18:21:32 -0700 Subject: [PATCH] Sign up restriction button on dashboard Fix #66, #74 --- .github/workflows/publish-docs.yaml | 2 +- apps/backend/.env | 1 + .../migration.sql | 2 + apps/backend/prisma/schema.prisma | 1 + apps/backend/prisma/seed.ts | 142 +++++++++++------- .../oauth/callback/[provider_id]/route.tsx | 7 +- .../v1/auth/otp/send-sign-in-code/route.tsx | 4 + .../api/v1/auth/password/sign-up/route.tsx | 4 + .../src/app/api/v1/internal/projects/crud.tsx | 11 +- .../src/app/api/v1/projects/current/crud.tsx | 1 + apps/backend/src/lib/projects.tsx | 1 + apps/dashboard/prisma/schema.prisma | 1 + .../[projectId]/auth-methods/page-client.tsx | 26 +++- .../[projectId]/auth-methods/page.tsx | 2 +- .../[projectId]/auth-methods/providers.tsx | 2 +- .../[projectId]/domains/page-client.tsx | 8 +- .../data-table/elements/toolbar.tsx | 2 - .../dashboard/src/components/project-card.tsx | 4 +- apps/dashboard/src/components/settings.tsx | 36 +++-- .../src/route-handlers/smart-request.tsx | 2 +- apps/e2e/tests/backend/backend-helpers.ts | 39 +++-- .../api/v1/auth/oauth/callback.test.ts | 29 +++- .../api/v1/auth/otp/send-sign-in-code.test.ts | 48 +++++- .../endpoints/api/v1/auth/otp/sign-in.test.ts | 29 +++- .../api/v1/auth/password/sign-up.test.ts | 54 ++++++- .../api/v1/internal/api-keys.test.ts | 2 +- .../api/v1/internal/projects.test.ts | 14 +- .../backend/endpoints/api/v1/projects.test.ts | 28 +++- .../endpoints/api/v1/team-memberships.test.ts | 2 +- .../v1/team-permission-definitions.test.ts | 4 +- .../endpoints/api/v1/team-permissions.test.ts | 5 +- .../src/interface/crud/projects.ts | 3 + packages/stack-shared/src/known-errors.tsx | 11 ++ packages/stack-shared/src/schema-fields.ts | 1 + .../src/components/simple-tooltip.tsx | 2 +- packages/stack-ui/src/components/ui/card.tsx | 17 ++- packages/stack/src/lib/stack-app.ts | 6 + 37 files changed, 429 insertions(+), 124 deletions(-) create mode 100644 apps/backend/prisma/migrations/20240809231417_disable_sign_up/migration.sql diff --git a/.github/workflows/publish-docs.yaml b/.github/workflows/publish-docs.yaml index 3b53190b4..1cd316c79 100644 --- a/.github/workflows/publish-docs.yaml +++ b/.github/workflows/publish-docs.yaml @@ -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 diff --git a/apps/backend/.env b/apps/backend/.env index 9c3d49779..6d8a2e313 100644 --- a/apps/backend/.env +++ b/apps/backend/.env @@ -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 diff --git a/apps/backend/prisma/migrations/20240809231417_disable_sign_up/migration.sql b/apps/backend/prisma/migrations/20240809231417_disable_sign_up/migration.sql new file mode 100644 index 000000000..c46624bf7 --- /dev/null +++ b/apps/backend/prisma/migrations/20240809231417_disable_sign_up/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "ProjectConfig" ADD COLUMN "signUpEnabled" BOOLEAN NOT NULL DEFAULT true; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 0be678afd..d0445e654 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -40,6 +40,7 @@ model ProjectConfig { updatedAt DateTime @updatedAt allowLocalhost Boolean + signUpEnabled Boolean @default(true) credentialEnabled Boolean magicLinkEnabled Boolean diff --git a/apps/backend/prisma/seed.ts b/apps/backend/prisma/seed.ts index 347bde4cf..06f981374 100644 --- a/apps/backend/prisma/seed.ts +++ b/apps/backend/prisma/seed.ts @@ -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!'); } diff --git a/apps/backend/src/app/api/v1/auth/oauth/callback/[provider_id]/route.tsx b/apps/backend/src/app/api/v1/auth/oauth/callback/[provider_id]/route.tsx index 539787412..17d0e5907 100644 --- a/apps/backend/src/app/api/v1/auth/oauth/callback/[provider_id]/route.tsx +++ b/apps/backend/src/app/api/v1/auth/oauth/callback/[provider_id]/route.tsx @@ -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 { diff --git a/apps/backend/src/app/api/v1/auth/otp/send-sign-in-code/route.tsx b/apps/backend/src/app/api/v1/auth/otp/send-sign-in-code/route.tsx index 10396ce51..6acf041b3 100644 --- a/apps/backend/src/app/api/v1/auth/otp/send-sign-in-code/route.tsx +++ b/apps/backend/src/app/api/v1/auth/otp/send-sign-in-code/route.tsx @@ -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, "projectUserId" | "displayName" | "primaryEmail"> | null = userPrisma; if (!userObj) { // TODO this should be in the same transaction as the read above diff --git a/apps/backend/src/app/api/v1/auth/password/sign-up/route.tsx b/apps/backend/src/app/api/v1/auth/password/sign-up/route.tsx index 6e6dc49c8..16c5868b9 100644 --- a/apps/backend/src/app/api/v1/auth/password/sign-up/route.tsx +++ b/apps/backend/src/app/api/v1/auth/password/sign-up/route.tsx @@ -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: { diff --git a/apps/backend/src/app/api/v1/internal/projects/crud.tsx b/apps/backend/src/app/api/v1/internal/projects/crud.tsx index 0a5b81e0b..c925c0674 100644 --- a/apps/backend/src/app/api/v1/internal/projects/crud.tsx +++ b/apps/backend/src/app/api/v1/internal/projects/crud.tsx @@ -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, diff --git a/apps/backend/src/app/api/v1/projects/current/crud.tsx b/apps/backend/src/app/api/v1/projects/current/crud.tsx index ee3da9aa0..1f7aba68d 100644 --- a/apps/backend/src/app/api/v1/projects/current/crud.tsx +++ b/apps/backend/src/app/api/v1/projects/current/crud.tsx @@ -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, diff --git a/apps/backend/src/lib/projects.tsx b/apps/backend/src/lib/projects.tsx index 53a056e55..067789994 100644 --- a/apps/backend/src/lib/projects.tsx +++ b/apps/backend/src/lib/projects.tsx @@ -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, diff --git a/apps/dashboard/prisma/schema.prisma b/apps/dashboard/prisma/schema.prisma index 0be678afd..d0445e654 100644 --- a/apps/dashboard/prisma/schema.prisma +++ b/apps/dashboard/prisma/schema.prisma @@ -40,6 +40,7 @@ model ProjectConfig { updatedAt DateTime @updatedAt allowLocalhost Boolean + signUpEnabled Boolean @default(true) credentialEnabled Boolean magicLinkEnabled Boolean diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/page-client.tsx index 443456626..be729d145 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/page-client.tsx @@ -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 ( - + + + Email-based + - - - + + SSO (OAuth) + {allProviders.map((id) => { const provider = oauthProviders.find((provider) => provider.id === id); return ; })} + + { + 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." + /> + ); } diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/page.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/page.tsx index 8daa67826..a78d467a0 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/page.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/page.tsx @@ -1,7 +1,7 @@ import PageClient from "./page-client"; export const metadata = { - title: "Auth Methods", + title: "Auth Settings", }; export default function Page() { diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/providers.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/providers.tsx index fbfec63ba..a47d9c6bc 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/providers.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/providers.tsx @@ -186,7 +186,7 @@ export function ProviderSettingSwitch(props: Props) {
{toTitle(props.id)} {isShared && enabled && - + Shared keys } diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/domains/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/domains/page-client.tsx index 57c0ce184..c23152b3f 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/domains/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/domains/page-client.tsx @@ -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 should be disabled in production. + } /> - - - - When enabled, allow access from all localhost URLs by default. This makes development easier but should be disabled in production. - ); diff --git a/apps/dashboard/src/components/data-table/elements/toolbar.tsx b/apps/dashboard/src/components/data-table/elements/toolbar.tsx index 4c2e3da10..abdd8735d 100644 --- a/apps/dashboard/src/components/data-table/elements/toolbar.tsx +++ b/apps/dashboard/src/components/data-table/elements/toolbar.tsx @@ -71,12 +71,10 @@ export function DataTableToolbar({ 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); }} diff --git a/apps/dashboard/src/components/project-card.tsx b/apps/dashboard/src/components/project-card.tsx index fae51b99b..24bb666ae 100644 --- a/apps/dashboard/src/components/project-card.tsx +++ b/apps/dashboard/src/components/project-card.tsx @@ -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 }) { {project.displayName} {project.description} - + {project.userCount} users diff --git a/apps/dashboard/src/components/settings.tsx b/apps/dashboard/src/components/settings.tsx index 5b14716d0..46fcea53d 100644 --- a/apps/dashboard/src/components/settings.tsx +++ b/apps/dashboard/src/components/settings.tsx @@ -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 ( - - {props.title} - {props.description && {props.description}} - + {(props.title || props.description) && ( + + {props.title && {props.title}} + {props.description && {props.description}} + + )} {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, @@ -62,15 +65,18 @@ export function SettingSwitch(props: { }; return ( -
- - - {showActions && props.actions} +
+
+ + + {showActions && props.actions} +
+ {props.hint && {props.hint}}
); } diff --git a/apps/dashboard/src/route-handlers/smart-request.tsx b/apps/dashboard/src/route-handlers/smart-request.tsx index a27165f48..1a193668c 100644 --- a/apps/dashboard/src/route-handlers/smart-request.tsx +++ b/apps/dashboard/src/route-handlers/smart-request.tsx @@ -131,7 +131,7 @@ async function parseAuth(req: NextRequest): Promise { 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: { diff --git a/apps/e2e/tests/backend/backend-helpers.ts b/apps/e2e/tests/backend/backend-helpers.ts index 9920d9f1e..0595f53bd 100644 --- a/apps/e2e/tests/backend/backend-helpers.ts +++ b/apps/e2e/tests/backend/backend-helpers.ts @@ -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 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) { 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) { 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) { + const createResult = await Project.createAndGetAdminToken(body); + backendContext.set({ + projectKeys: { + projectId: createResult.projectId, + adminAccessToken: createResult.adminAccessToken, + }, + }); + return createResult; + } } export namespace Team { diff --git a/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/callback.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/callback.test.ts index f5393f38e..b811d5e72 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/callback.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/callback.test.ts @@ -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": ' at path '/'>, + "x-stack-known-error": "SIGN_UP_NOT_ENABLED", +
-
+
{props.tooltip}
diff --git a/packages/stack-ui/src/components/ui/card.tsx b/packages/stack-ui/src/components/ui/card.tsx index fa9a25559..50763bc57 100644 --- a/packages/stack-ui/src/components/ui/card.tsx +++ b/packages/stack-ui/src/components/ui/card.tsx @@ -38,7 +38,7 @@ const CardHeader = React.forwardRef< >(({ className, ...props }, ref) => (
)); @@ -72,10 +72,21 @@ const CardContent = React.forwardRef< HTMLDivElement, React.HTMLAttributes >(({ className, ...props }, ref) => ( -
+
)); CardContent.displayName = "CardContent"; +const CardSubtitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); + const CardFooter = React.forwardRef< HTMLDivElement, React.HTMLAttributes @@ -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 }; diff --git a/packages/stack/src/lib/stack-app.ts b/packages/stack/src/lib/stack-app.ts index 65bf9f5e4..aa44f4fe4 100644 --- a/packages/stack/src/lib/stack-app.ts +++ b/packages/stack/src/lib/stack-app.ts @@ -675,6 +675,7 @@ class _StackClientAppImpl ({ @@ -1788,6 +1789,7 @@ class _StackAdminAppImpl