diff --git a/.github/assets/logo.png b/.github/assets/logo.png index 0ba1cf944f..d7112e209d 100644 Binary files a/.github/assets/logo.png and b/.github/assets/logo.png differ diff --git a/.github/workflows/e2e-api-tests.yaml b/.github/workflows/e2e-api-tests.yaml index 5e314fa762..40f018031b 100644 --- a/.github/workflows/e2e-api-tests.yaml +++ b/.github/workflows/e2e-api-tests.yaml @@ -65,9 +65,6 @@ jobs: - name: Create .env.test.local file for examples/supabase run: cp examples/supabase/.env.development examples/supabase/.env.test.local - - - name: Create .env.test.local file for packages/stack-proxy - run: cp packages/stack-proxy/.env.development packages/stack-proxy/.env.test.local - name: Build run: pnpm build diff --git a/.github/workflows/lint-and-build.yaml b/.github/workflows/lint-and-build.yaml index a72c46d9f9..778421b902 100644 --- a/.github/workflows/lint-and-build.yaml +++ b/.github/workflows/lint-and-build.yaml @@ -64,9 +64,6 @@ jobs: - name: Create .env.production.local file for examples/supabase run: cp examples/supabase/.env.development examples/supabase/.env.production.local - - name: Create .env.production.local file for packages/stack-proxy - run: cp packages/stack-proxy/.env.development packages/stack-proxy/.env.production.local - - name: Build run: pnpm build diff --git a/.github/workflows/preview-docs.yaml b/.github/workflows/preview-docs.yaml index e362b4d06b..1507212c56 100644 --- a/.github/workflows/preview-docs.yaml +++ b/.github/workflows/preview-docs.yaml @@ -54,9 +54,6 @@ jobs: - name: Create .env.production.local file for examples/supabase run: cp examples/supabase/.env.development examples/supabase/.env.production.local - - name: Create .env.production.local file for packages/stack-proxy - run: cp packages/stack-proxy/.env.development packages/stack-proxy/.env.production.local - - name: Build run: pnpm build diff --git a/.github/workflows/publish-docs.yaml b/.github/workflows/publish-docs.yaml index 39212ece75..2cb4cd48b9 100644 --- a/.github/workflows/publish-docs.yaml +++ b/.github/workflows/publish-docs.yaml @@ -71,9 +71,6 @@ jobs: - name: Create .env.production.local file for examples/supabase run: cp examples/supabase/.env.development examples/supabase/.env.production.local - - - name: Create .env.production.local file for packages/stack-proxy - run: cp packages/stack-proxy/.env.development packages/stack-proxy/.env.production.local - name: Build run: pnpm build diff --git a/.github/workflows/table-of-contents.yaml b/.github/workflows/table-of-contents.yaml index 484dde25aa..8e0959e88d 100644 --- a/.github/workflows/table-of-contents.yaml +++ b/.github/workflows/table-of-contents.yaml @@ -10,3 +10,4 @@ jobs: - uses: technote-space/toc-generator@v4 with: TOC_TITLE: "" + TARGET_PATHS: "README*.md,CONTRIBUTING.md" diff --git a/.vscode/settings.json b/.vscode/settings.json index 8894f49515..4ff4cd9241 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -62,5 +62,10 @@ "source.organizeImports": "explicit" } }, - "terminal.integrated.wordSeparators": " (){}',\"`─‘’“”|" + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit" + }, + "terminal.integrated.wordSeparators": " (){}',\"`─‘’“”|", + "editor.formatOnSave": false, + "prettier.enable": false } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2d0817059a..a28fac8ca4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,6 +4,18 @@ Welcome to Stack Auth! Due to the nature of authentication, this may not be the easiest project to contribute to, so if you are looking for projects to help gain programming experience, we may not be a great match. If you're looking for projects for beginners, check out [Awesome First PR Opportunities](https://github.com/MunGell/awesome-for-beginners). +## Table of contents + + + + +- [How to contribute](#how-to-contribute) +- [Security & bug bounties](#security--bug-bounties) +- [Before creating a pull request](#before-creating-a-pull-request) + + + + ## How to contribute If you think Stack Auth is a good fit for you, follow these steps: diff --git a/README.md b/README.md index 0c56ba150e..b0e324b486 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ We support Next.js frontends, along with any backend that can use our [REST API] - [🏗 Development & Contribution](#-development--contribution) - [Requirements](#requirements) - [Setup](#setup) + - [Development environment port mapping](#development-environment-port-mapping) - [Database migrations](#database-migrations) - [Chat with the codebase](#chat-with-the-codebase) - [Architecture overview](#architecture-overview) @@ -52,6 +53,8 @@ If you answered "no" to any of these questions, then that's how Stack Auth is di ## ✨ Features +To get notified first when we add new features, please subscribe to [our newsletter](https://stack-auth.beehiiv.com/subscribe). + | | | |-|:-:| |

`` and ``

Authentication components that support OAuth, password credentials, and magic links, with shared development keys to make setup faster. All components support dark/light modes. | Sign-in component | @@ -99,7 +102,7 @@ This is for you if you want to contribute to the Stack project or run the Stack ### Setup -Pre-populated .env files for the setup below are available and used by default in `.env.development` in each of the packages. You should copy all the `.env.development` files to `.env.local` in the respective packages for local development. +Pre-populated .env files for the setup below are available and used by default in `.env.development` in each of the packages. (Note: If you're creating a production build (eg. with `pnpm run build`), you must supply the environment variables manually.) In a new terminal: @@ -107,7 +110,7 @@ In a new terminal: pnpm install # Run build to build everything once -pnpm run build +pnpm run build:dev # reset & start the dependencies (DB, Inbucket, etc.) as Docker containers, seeding the DB with the Prisma schema pnpm run start-deps @@ -121,7 +124,7 @@ pnpm run dev pnpm run test ``` -You can now open the dashboard at [http://localhost:8101](http://localhost:8101), API on port 8102, demo on port 8103, docs on port 8104, Inbucket (e-mails) on port 8105, and Prisma Studio on port 8106. +You can now open the dashboard at [http://localhost:8101](http://localhost:8101), API on port 8102, demo on port 8103, docs on port 8104, Inbucket (e-mails) on port 8105, and Prisma Studio on port 8106. See the section below on more information on the ports of the running services. Your IDE may show an error on all `@stackframe/XYZ` imports. To fix this, simply restart the TypeScript language server; for example, in VSCode you can open the command palette (Ctrl+Shift+P) and run `Developer: Reload Window` or `TypeScript: Restart TS server`. @@ -131,6 +134,25 @@ You can also open Prisma Studio to see the database interface and edit data dire pnpm run prisma studio ``` +### Development environment port mapping + +8101. Dashboard (equivalent to https://app.stack-auth.com) +8102. Backend (equivalent to https://api.stack-auth.com) +8103. Demo app (equivalent to https://demo.stack-auth.com) +8104. Docs (equivalent to https://docs.stack-auth.com) +8105. Inbucket (e-mails) +8106. Prisma Studio +8107. Jaeger UI/OpenTelemetry (for performance tracing) +8108. `examples/docs-examples` +8109. `examples/partial-prerendering` +8110. `examples/cjs-test` +8111. `examples/e-commerce` +8112. `examples/middleware` +8113. Svix server (for webhooks) +8114. OAuth mock server +8115. `examples/supabase` + + ### Database migrations If you make changes to the Prisma schema, you need to run the following command to create a migration: @@ -151,16 +173,16 @@ Storia trained an [AI on our codebase](https://sage.storia.ai/stack-auth) that c User((User)) Admin((Admin)) subgraph "Stack Auth System" - Dashboard[Stack Dashboard
Next.js] - Backend[Stack API Backend
Next.js] + Dashboard[Stack Dashboard
/apps/dashboard] + Backend[Stack API Backend
/apps/backend] Database[(PostgreSQL Database)] EmailService[Email Service
Inbucket] WebhookService[Webhook Service
Svix] - subgraph "Shared Packages" - StackSDK[Stack
Client SDK] - StackUI[Stack UI
React Components] - StackShared[Stack Shared
Utilities] - StackEmails[Stack Emails
Email Templates] + StackSDK[Client SDK
/packages/stack] + subgraph Shared + StackUI[Stack UI
/packages/stack-ui] + StackShared[Stack Shared
/packages/stack-shared] + StackEmails[Stack Emails
/packages/stack-emails] end end Admin --> Dashboard @@ -169,13 +191,11 @@ Storia trained an [AI on our codebase](https://sage.storia.ai/stack-auth) that c Backend --> Database Backend --> EmailService Backend --> WebhookService - Dashboard --> StackUI - Dashboard --> StackShared - Dashboard --> StackEmails + Dashboard --> Shared Dashboard --> StackSDK StackSDK --HTTP Requests--> Backend - Backend --> StackShared - Backend --> StackEmails + StackSDK --> Shared + Backend --> Shared classDef container fill:#1168bd,stroke:#0b4884,color:#ffffff classDef database fill:#2b78e4,stroke:#1a4d91,color:#ffffff classDef external fill:#999999,stroke:#666666,color:#ffffff diff --git a/apps/backend/CHANGELOG.md b/apps/backend/CHANGELOG.md index 07c907b7ea..7e70a5a44f 100644 --- a/apps/backend/CHANGELOG.md +++ b/apps/backend/CHANGELOG.md @@ -1,5 +1,76 @@ # @stackframe/stack-backend +## 2.6.11 + +### Patch Changes + +- fixed account settings bugs +- Updated dependencies + - @stackframe/stack-emails@2.6.11 + - @stackframe/stack-shared@2.6.11 + +## 2.6.10 + +### Patch Changes + +- Various bugfixes +- Updated dependencies + - @stackframe/stack-emails@2.6.10 + - @stackframe/stack-shared@2.6.10 + +## 2.6.9 + +### Patch Changes + +- - New contact channel API + - Fixed some visual gitches and typos + - Bug fixes +- Updated dependencies + - @stackframe/stack-emails@2.6.9 + - @stackframe/stack-shared@2.6.9 + +## 2.6.8 + +### Patch Changes + +- Bugfixes +- Updated dependencies + - @stackframe/stack-shared@2.6.8 + - @stackframe/stack-emails@2.6.8 + +## 2.6.7 + +### Patch Changes + +- Bugfixes + - @stackframe/stack-shared@2.6.7 + - @stackframe/stack-emails@2.6.7 + +## 2.6.6 + +### Patch Changes + +- @stackframe/stack-emails@2.6.6 +- @stackframe/stack-shared@2.6.6 + +## 2.6.5 + +### Patch Changes + +- Minor improvements +- Updated dependencies + - @stackframe/stack-emails@2.6.5 + - @stackframe/stack-shared@2.6.5 + +## 2.6.4 + +### Patch Changes + +- fixed small problems +- Updated dependencies + - @stackframe/stack-emails@2.6.4 + - @stackframe/stack-shared@2.6.4 + ## 2.6.3 ### Patch Changes diff --git a/apps/backend/next.config.mjs b/apps/backend/next.config.mjs index a38d1c699e..b2f09d8d90 100644 --- a/apps/backend/next.config.mjs +++ b/apps/backend/next.config.mjs @@ -96,8 +96,4 @@ const nextConfig = { }, }; -export default withConfiguredSentryConfig( - withBundleAnalyzer( - nextConfig - ) -); +export default withConfiguredSentryConfig(withBundleAnalyzer(nextConfig)); diff --git a/apps/backend/package.json b/apps/backend/package.json index 8ba1e50cdb..5fb7beada3 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -1,6 +1,6 @@ { "name": "@stackframe/stack-backend", - "version": "2.6.3", + "version": "2.6.11", "private": true, "scripts": { "clean": "rimraf .next && rimraf node_modules", @@ -48,11 +48,12 @@ "@stackframe/stack-emails": "workspace:*", "@stackframe/stack-shared": "workspace:*", "@vercel/analytics": "^1.2.2", + "@vercel/functions": "^1.4.2", "@vercel/otel": "^1.10.0", "bcrypt": "^5.1.1", "dotenv-cli": "^7.3.0", "jose": "^5.2.2", - "next": "^14.1", + "next": "^14.2.5", "next-runtime-env": "^3.2.2", "nodemailer": "^6.9.10", "openid-client": "^5.6.4", diff --git a/apps/backend/prisma/migrations/20241013185548_remove_client_id_unique/migration.sql b/apps/backend/prisma/migrations/20241013185548_remove_client_id_unique/migration.sql new file mode 100644 index 0000000000..02d6fb69b2 --- /dev/null +++ b/apps/backend/prisma/migrations/20241013185548_remove_client_id_unique/migration.sql @@ -0,0 +1,2 @@ +-- DropIndex +DROP INDEX "StandardOAuthProviderConfig_projectConfigId_clientId_key"; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 60aac56469..1b888e32f4 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -468,8 +468,6 @@ model StandardOAuthProviderConfig { // each type of standard OAuth provider can only be used once per project @@id([projectConfigId, id]) - // each client id can only be used once per project - @@unique([projectConfigId, clientId]) } model AuthMethod { 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 49a0a4f850..7dfc0fe57f 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 @@ -1,4 +1,5 @@ import { usersCrudHandlers } from "@/app/api/v1/users/crud"; +import { getAuthContactChannel } from "@/lib/contact-channel"; import { getProject } from "@/lib/projects"; import { validateRedirectUrl } from "@/lib/redirect-urls"; import { oauthCookieSchema } from "@/lib/tokens"; @@ -9,7 +10,7 @@ import { InvalidClientError, Request as OAuthRequest, Response as OAuthResponse import { KnownError, KnownErrors } from "@stackframe/stack-shared"; import { ProjectsCrud } from "@stackframe/stack-shared/dist/interface/crud/projects"; import { yupMixed, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; -import { StackAssertionError, StatusError, captureError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; +import { StackAssertionError, StatusError } from "@stackframe/stack-shared/dist/utils/errors"; import { extractScopes } from "@stackframe/stack-shared/dist/utils/strings"; import { cookies } from "next/headers"; import { redirect } from "next/navigation"; @@ -268,6 +269,24 @@ const handler = createSmartRouteHandler({ if (!project.config.sign_up_enabled) { throw new KnownErrors.SignUpNotEnabled(); } + + let primaryEmailAuthEnabled = true; + if (userInfo.email) { + const oldContactChannel = await getAuthContactChannel( + prismaClient, + { + projectId: outerInfo.projectId, + type: 'EMAIL', + value: userInfo.email, + } + ); + + if (oldContactChannel && oldContactChannel.usedForAuth) { + primaryEmailAuthEnabled = false; + } + // TODO: check whether this OAuth account can be used to login to another existing account instead + } + const newAccount = await usersCrudHandlers.adminCreate({ project, data: { @@ -275,7 +294,7 @@ const handler = createSmartRouteHandler({ profile_image_url: userInfo.profileImageUrl || undefined, primary_email: userInfo.email, primary_email_verified: userInfo.emailVerified, - primary_email_auth_enabled: false, + primary_email_auth_enabled: primaryEmailAuthEnabled, oauth_providers: [{ id: provider.id, account_id: userInfo.accountId, @@ -315,4 +334,4 @@ const handler = createSmartRouteHandler({ }); export const GET = handler; -export const POST = handler; \ No newline at end of file +export const POST = handler; diff --git a/apps/backend/src/app/api/v1/auth/oauth/connected-accounts/[provider_id]/access-token/route.tsx b/apps/backend/src/app/api/v1/auth/oauth/connected-accounts/[provider_id]/access-token/route.tsx index cf91b8559a..cd1e21ce05 100644 --- a/apps/backend/src/app/api/v1/auth/oauth/connected-accounts/[provider_id]/access-token/route.tsx +++ b/apps/backend/src/app/api/v1/auth/oauth/connected-accounts/[provider_id]/access-token/route.tsx @@ -36,4 +36,4 @@ export const POST = createSmartRouteHandler({ body: await response.json() }; } -}); \ No newline at end of file +}); 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 83c6df4c1e..c5ef25bfa6 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 @@ -2,10 +2,11 @@ import { prismaClient } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { KnownErrors } from "@stackframe/stack-shared"; import { adaptSchema, clientOrHigherAuthTypeSchema, emailOtpSignInCallbackUrlSchema, signInEmailSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; -import { StackAssertionError, StatusError } from "@stackframe/stack-shared/dist/utils/errors"; +import { StackAssertionError, StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import semver from "semver"; import { usersCrudHandlers } from "../../../users/crud"; import { signInVerificationCodeHandler } from "../sign-in/verification-code-handler"; +import { getAuthContactChannel } from "@/lib/contact-channel"; export const POST = createSmartRouteHandler({ metadata: { @@ -39,53 +40,67 @@ export const POST = createSmartRouteHandler({ throw new StatusError(StatusError.Forbidden, "Magic link is not enabled for this project"); } - const contactChannel = await prismaClient.contactChannel.findUnique({ - where: { - projectId_type_value_usedForAuth: { - projectId: project.id, - type: "EMAIL", - value: email, - usedForAuth: "TRUE", - } - }, - include: { - projectUser: { - include: { - authMethods: { - include: { - otpAuthMethod: true, - } - } - } - } + const contactChannel = await getAuthContactChannel( + prismaClient, + { + projectId: project.id, + type: "EMAIL", + value: email, } - }); + ); - const otpAuthMethod = contactChannel?.projectUser.authMethods.find((m) => m.otpAuthMethod)?.otpAuthMethod; + let user; + let isNewUser; - const isNewUser = !otpAuthMethod; - if (isNewUser && !project.config.sign_up_enabled) { - throw new KnownErrors.SignUpNotEnabled(); - } + if (contactChannel) { + const otpAuthMethod = contactChannel.projectUser.authMethods.find((m) => m.otpAuthMethod)?.otpAuthMethod; - let user; + if (contactChannel.isVerified) { + if (!otpAuthMethod) { + // automatically merge the otp auth method with the existing account - if (!otpAuthMethod) { - // TODO this should be in the same transaction as the read above - user = await usersCrudHandlers.adminCreate({ - project, - data: { - primary_email_auth_enabled: true, - primary_email: email, - primary_email_verified: false, - }, - allowedErrorTypes: [KnownErrors.UserEmailAlreadyExists], - }); + // TODO: use the contact channel handler + const rawProject = await prismaClient.project.findUnique({ + where: { + id: project.id, + }, + include: { + config: { + include: { + authMethodConfigs: { + include: { + otpConfig: true, + } + } + } + } + } + }); + + const otpAuthMethodConfig = rawProject?.config.authMethodConfigs.find((m) => m.otpConfig) ?? throwErr("OTP auth method config not found."); + await prismaClient.authMethod.create({ + data: { + projectUserId: contactChannel.projectUser.projectUserId, + projectId: project.id, + projectConfigId: project.config.id, + authMethodConfigId: otpAuthMethodConfig.id, + }, + }); + } + + user = await usersCrudHandlers.adminRead({ + project, + user_id: contactChannel.projectUser.projectUserId, + }); + } else { + throw new KnownErrors.UserEmailAlreadyExists(); + } + isNewUser = false; } else { - user = await usersCrudHandlers.adminRead({ - project, - user_id: contactChannel.projectUser.projectUserId, - }); + if (!project.config.sign_up_enabled) { + throw new KnownErrors.SignUpNotEnabled(); + } + isNewUser = true; } let type: "legacy" | "standard"; @@ -101,11 +116,11 @@ export const POST = createSmartRouteHandler({ callbackUrl, method: { email, type }, data: { - user_id: user.id, + user_id: user?.id, is_new_user: isNewUser, }, }, - { user } + { email } ); return { diff --git a/apps/backend/src/app/api/v1/auth/otp/sign-in/verification-code-handler.tsx b/apps/backend/src/app/api/v1/auth/otp/sign-in/verification-code-handler.tsx index 303bd80511..149bd2b9ef 100644 --- a/apps/backend/src/app/api/v1/auth/otp/sign-in/verification-code-handler.tsx +++ b/apps/backend/src/app/api/v1/auth/otp/sign-in/verification-code-handler.tsx @@ -1,12 +1,12 @@ import { sendEmailFromTemplate } from "@/lib/emails"; import { createAuthTokens } from "@/lib/tokens"; -import { prismaClient } from "@/prisma-client"; import { createVerificationCodeHandler } from "@/route-handlers/verification-code-handler"; import { VerificationCodeType } from "@prisma/client"; -import { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/users"; +import { KnownErrors } from "@stackframe/stack-shared"; import { signInResponseSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; -import { createMfaRequiredError } from "../../mfa/sign-in/verification-code-handler"; import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; +import { usersCrudHandlers } from "../../../users/crud"; +import { createMfaRequiredError } from "../../mfa/sign-in/verification-code-handler"; export const signInVerificationCodeHandler = createVerificationCodeHandler({ metadata: { @@ -23,7 +23,7 @@ export const signInVerificationCodeHandler = createVerificationCodeHandler({ }, type: VerificationCodeType.ONE_TIME_PASSWORD, data: yupObject({ - user_id: yupString().required(), + user_id: yupString().uuid().optional(), is_new_user: yupBoolean().required(), }), method: yupObject({ @@ -35,15 +35,13 @@ export const signInVerificationCodeHandler = createVerificationCodeHandler({ bodyType: yupString().oneOf(["json"]).required(), body: signInResponseSchema.required(), }), - async send(codeObj, createOptions, sendOptions: { user: UsersCrud["Admin"]["Read"] }) { + async send(codeObj, createOptions, sendOptions: { email: string }) { await sendEmailFromTemplate({ project: createOptions.project, - user: sendOptions.user, email: createOptions.method.email, + user: null, templateType: "magic_link", extraVariables: { - userDisplayName: sendOptions.user.display_name, - userPrimaryEmail: sendOptions.user.primary_email, magicLink: codeObj.link.toString(), otp: codeObj.code.slice(0, 6).toUpperCase(), }, @@ -55,59 +53,42 @@ export const signInVerificationCodeHandler = createVerificationCodeHandler({ }; }, async handler(project, { email }, data) { - const contactChannel = await prismaClient.contactChannel.findUnique({ - where: { - projectId_type_value_usedForAuth: { - projectId: project.id, - type: "EMAIL", - value: email, - usedForAuth: "TRUE", - } - }, - include: { - projectUser: { - include: { - authMethods: { - include: { - otpAuthMethod: true, - } - } - } - } + let user; + // the user_id check is just for the migration + // we can rely only on is_new_user starting from the next release + if (!data.user_id) { + if (!data.is_new_user) { + throw new StackAssertionError("When user ID is not provided, the user must be new"); } - }); - const otpAuthMethod = contactChannel?.projectUser.authMethods.find((m) => m.otpAuthMethod)?.otpAuthMethod; - - if (!contactChannel || !otpAuthMethod) { - throw new StackAssertionError("Tried to use OTP sign in but auth method was not found?"); + user = await usersCrudHandlers.adminCreate({ + project, + data: { + primary_email: email, + primary_email_verified: true, + primary_email_auth_enabled: true, + otp_auth_enabled: true, + }, + allowedErrorTypes: [KnownErrors.UserEmailAlreadyExists], + }); + } else { + user = await usersCrudHandlers.adminRead({ + project, + user_id: data.user_id, + }); } - if (contactChannel.projectUser.requiresTotpMfa) { + if (user.requires_totp_mfa) { throw await createMfaRequiredError({ project, isNewUser: data.is_new_user, - userId: contactChannel.projectUser.projectUserId, + userId: user.id, }); } - await prismaClient.contactChannel.update({ - where: { - projectId_projectUserId_type_value: { - projectId: project.id, - projectUserId: contactChannel.projectUser.projectUserId, - type: "EMAIL", - value: email, - } - }, - data: { - isVerified: true, - }, - }); - const { refreshToken, accessToken } = await createAuthTokens({ projectId: project.id, - projectUserId: contactChannel.projectUser.projectUserId, + projectUserId: user.id, useLegacyGlobalJWT: project.config.legacy_global_jwt_signing, }); @@ -118,7 +99,7 @@ export const signInVerificationCodeHandler = createVerificationCodeHandler({ refresh_token: refreshToken, access_token: accessToken, is_new_user: data.is_new_user, - user_id: data.user_id, + user_id: user.id, }, }; }, diff --git a/apps/backend/src/app/api/v1/auth/password/set/route.tsx b/apps/backend/src/app/api/v1/auth/password/set/route.tsx new file mode 100644 index 0000000000..7ebf969500 --- /dev/null +++ b/apps/backend/src/app/api/v1/auth/password/set/route.tsx @@ -0,0 +1,95 @@ +import { prismaClient } from "@/prisma-client"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { KnownErrors } from "@stackframe/stack-shared"; +import { getPasswordError } from "@stackframe/stack-shared/dist/helpers/password"; +import { adaptSchema, clientOrHigherAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { StackAssertionError, StatusError } from "@stackframe/stack-shared/dist/utils/errors"; +import { hashPassword } from "@stackframe/stack-shared/dist/utils/password"; + +export const POST = createSmartRouteHandler({ + metadata: { + summary: "Set password", + description: "Set a new password for the current user", + tags: ["Password"], + }, + request: yupObject({ + auth: yupObject({ + type: clientOrHigherAuthTypeSchema, + project: adaptSchema, + user: adaptSchema.required(), + }).required(), + body: yupObject({ + password: yupString().required(), + }).required(), + headers: yupObject({}).required(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).required(), + bodyType: yupString().oneOf(["success"]).required(), + }), + async handler({ auth: { project, user }, body: { password } }) { + if (!project.config.credential_enabled) { + throw new KnownErrors.PasswordAuthenticationNotEnabled(); + } + + const passwordError = getPasswordError(password); + if (passwordError) { + throw passwordError; + } + + await prismaClient.$transaction(async (tx) => { + const authMethodConfig = await tx.passwordAuthMethodConfig.findMany({ + where: { + projectConfigId: project.config.id, + authMethodConfig: { + enabled: true, + }, + }, + }); + + if (authMethodConfig.length > 1) { + throw new StackAssertionError("Project has multiple password auth method configs.", { projectId: project.id }); + } + + if (authMethodConfig.length === 0) { + throw new KnownErrors.PasswordAuthenticationNotEnabled(); + } + + const authMethods = await tx.passwordAuthMethod.findMany({ + where: { + projectId: project.id, + projectUserId: user.id, + }, + }); + + if (authMethods.length > 1) { + throw new StackAssertionError("User has multiple password auth methods.", { + projectId: project.id, + projectUserId: user.id, + }); + } else if (authMethods.length === 1) { + throw new StatusError(StatusError.BadRequest, "User already has a password set."); + } + + await tx.authMethod.create({ + data: { + projectId: project.id, + projectUserId: user.id, + projectConfigId: project.config.id, + authMethodConfigId: authMethodConfig[0].authMethodConfigId, + passwordAuthMethod: { + create: { + passwordHash: await hashPassword(password), + projectUserId: user.id, + } + } + } + }); + }); + + return { + statusCode: 200, + bodyType: "success", + }; + }, +}); diff --git a/apps/backend/src/app/api/v1/auth/password/sign-in/route.tsx b/apps/backend/src/app/api/v1/auth/password/sign-in/route.tsx index 2341311bcc..955afdcf03 100644 --- a/apps/backend/src/app/api/v1/auth/password/sign-in/route.tsx +++ b/apps/backend/src/app/api/v1/auth/password/sign-in/route.tsx @@ -6,6 +6,7 @@ import { adaptSchema, clientOrHigherAuthTypeSchema, yupNumber, yupObject, yupStr import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; import { comparePassword } from "@stackframe/stack-shared/dist/utils/password"; import { createMfaRequiredError } from "../../mfa/sign-in/verification-code-handler"; +import { getAuthContactChannel } from "@/lib/contact-channel"; export const POST = createSmartRouteHandler({ metadata: { @@ -37,27 +38,14 @@ export const POST = createSmartRouteHandler({ throw new KnownErrors.PasswordAuthenticationNotEnabled(); } - const contactChannel = await prismaClient.contactChannel.findUnique({ - where: { - projectId_type_value_usedForAuth: { - projectId: project.id, - type: "EMAIL", - value: email, - usedForAuth: "TRUE", - } - }, - include: { - projectUser: { - include: { - authMethods: { - include: { - passwordAuthMethod: true, - } - } - } - } + const contactChannel = await getAuthContactChannel( + prismaClient, + { + projectId: project.id, + type: "EMAIL", + value: email, } - }); + ); const passwordAuthMethod = contactChannel?.projectUser.authMethods.find((m) => m.passwordAuthMethod)?.passwordAuthMethod; 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 e042a92c75..a6058afe6e 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 @@ -1,13 +1,13 @@ -import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; -import { yupObject, yupString, yupNumber, yupBoolean, yupArray, yupMixed } from "@stackframe/stack-shared/dist/schema-fields"; -import { adaptSchema, clientOrHigherAuthTypeSchema, emailVerificationCallbackUrlSchema, signInEmailSchema } from "@stackframe/stack-shared/dist/schema-fields"; -import { prismaClient } from "@/prisma-client"; +import { getAuthContactChannel } from "@/lib/contact-channel"; import { createAuthTokens } from "@/lib/tokens"; -import { getPasswordError } from "@stackframe/stack-shared/dist/helpers/password"; -import { StatusError, captureError } from "@stackframe/stack-shared/dist/utils/errors"; +import { prismaClient } from "@/prisma-client"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { KnownErrors } from "@stackframe/stack-shared"; -import { usersCrudHandlers } from "../../../users/crud"; +import { getPasswordError } from "@stackframe/stack-shared/dist/helpers/password"; +import { adaptSchema, clientOrHigherAuthTypeSchema, emailVerificationCallbackUrlSchema, signInEmailSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { captureError } from "@stackframe/stack-shared/dist/utils/errors"; import { contactChannelVerificationCodeHandler } from "../../../contact-channels/verify/verification-code-handler"; +import { usersCrudHandlers } from "../../../users/crud"; import { createMfaRequiredError } from "../../mfa/sign-in/verification-code-handler"; export const POST = createSmartRouteHandler({ @@ -50,15 +50,27 @@ export const POST = createSmartRouteHandler({ throw new KnownErrors.SignUpNotEnabled(); } + const contactChannel = await getAuthContactChannel( + prismaClient, + { + projectId: project.id, + type: "EMAIL", + value: email, + } + ); + + if (contactChannel) { + throw new KnownErrors.UserEmailAlreadyExists(); + } + const createdUser = await usersCrudHandlers.adminCreate({ project, data: { - primary_email_auth_enabled: true, primary_email: email, primary_email_verified: false, + primary_email_auth_enabled: true, password, }, - allowedErrorTypes: [KnownErrors.UserEmailAlreadyExists], }); try { diff --git a/apps/backend/src/app/api/v1/auth/password/update/route.tsx b/apps/backend/src/app/api/v1/auth/password/update/route.tsx index a066488ee2..765a62f270 100644 --- a/apps/backend/src/app/api/v1/auth/password/update/route.tsx +++ b/apps/backend/src/app/api/v1/auth/password/update/route.tsx @@ -3,7 +3,7 @@ import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { KnownErrors } from "@stackframe/stack-shared"; import { getPasswordError } from "@stackframe/stack-shared/dist/helpers/password"; import { adaptSchema, clientOrHigherAuthTypeSchema, yupNumber, yupObject, yupString, yupTuple } from "@stackframe/stack-shared/dist/schema-fields"; -import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; +import { StackAssertionError, StatusError } from "@stackframe/stack-shared/dist/utils/errors"; import { comparePassword, hashPassword } from "@stackframe/stack-shared/dist/utils/password"; export const POST = createSmartRouteHandler({ @@ -19,7 +19,6 @@ export const POST = createSmartRouteHandler({ user: adaptSchema.required(), }).required(), body: yupObject({ - auth_method_id: yupString().optional(), old_password: yupString().required(), new_password: yupString().required(), }).required(), @@ -31,7 +30,7 @@ export const POST = createSmartRouteHandler({ statusCode: yupNumber().oneOf([200]).required(), bodyType: yupString().oneOf(["success"]).required(), }), - async handler({ auth: { project, user }, body: { old_password, new_password, auth_method_id }, headers: { "x-stack-refresh-token": refreshToken } }, fullReq) { + async handler({ auth: { project, user }, body: { old_password, new_password }, headers: { "x-stack-refresh-token": refreshToken } }, fullReq) { if (!project.config.credential_enabled) { throw new KnownErrors.PasswordAuthenticationNotEnabled(); } @@ -49,22 +48,17 @@ export const POST = createSmartRouteHandler({ }, }); - let authMethod; if (authMethods.length > 1) { - if (!auth_method_id) { - throw new StatusError(StatusError.BadRequest, "auth_method_id is required when there are multiple password auth methods. If you see this error on the client, please upgrade your client to the latest version."); - } - authMethod = authMethods.find((x) => x.authMethodId === auth_method_id); - - if (!authMethod) { - throw new StatusError(StatusError.NotFound, "Auth method not found"); - } - } else if (authMethods.length === 1) { - authMethod = authMethods[0]; - } else { + throw new StackAssertionError("User has multiple password auth methods.", { + projectId: project.id, + projectUserId: user.id, + }); + } else if (authMethods.length === 0) { throw new KnownErrors.UserDoesNotHavePassword(); } + const authMethod = authMethods[0]; + if (!await comparePassword(old_password, authMethod.passwordHash)) { throw new KnownErrors.PasswordConfirmationMismatch(); } diff --git a/apps/backend/src/app/api/v1/auth/sessions/route.tsx b/apps/backend/src/app/api/v1/auth/sessions/route.tsx index 3db28164d1..5a362dbbad 100644 --- a/apps/backend/src/app/api/v1/auth/sessions/route.tsx +++ b/apps/backend/src/app/api/v1/auth/sessions/route.tsx @@ -37,10 +37,8 @@ export const POST = createSmartRouteHandler({ project: project, }); } catch (e) { - if (e instanceof CrudHandlerInvocationError) { - if (e.cause instanceof KnownErrors.UserNotFound) { - throw new KnownErrors.UserIdDoesNotExist(userId); - } + if (e instanceof CrudHandlerInvocationError && e.cause instanceof KnownErrors.UserNotFound) { + throw new KnownErrors.UserIdDoesNotExist(userId); } throw e; } diff --git a/apps/backend/src/app/api/v1/check-version/route.ts b/apps/backend/src/app/api/v1/check-version/route.ts new file mode 100644 index 0000000000..d3ba6e2a77 --- /dev/null +++ b/apps/backend/src/app/api/v1/check-version/route.ts @@ -0,0 +1,67 @@ +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { yupBoolean, yupNumber, yupObject, yupString, yupUnion } from "@stackframe/stack-shared/dist/schema-fields"; +import semver from "semver"; +import packageJson from "../../../../../package.json"; + +export const POST = createSmartRouteHandler({ + metadata: { + hidden: true, + }, + request: yupObject({ + method: yupString().oneOf(["POST"]).required(), + body: yupObject({ + clientVersion: yupString().required(), + }), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).required(), + bodyType: yupString().oneOf(["json"]).required(), + body: yupUnion( + yupObject({ + upToDate: yupBoolean().oneOf([true]).required(), + }), + yupObject({ + upToDate: yupBoolean().oneOf([false]).required(), + error: yupString().required(), + severe: yupBoolean().required(), + }), + ), + }), + handler: async (req) => { + const err = (severe: boolean, msg: string) => ({ + statusCode: 200, + bodyType: "json", + body: { + upToDate: false, + error: msg, + severe, + }, + } as const); + + const clientVersion = req.body.clientVersion; + if (!semver.valid(clientVersion)) return err(true, `The client version you specified (v${clientVersion}) is not a valid semver version. Please update to the latest version as soon as possible to ensure that you get the latest feature and security updates.`); + + const serverVersion = packageJson.version; + + if (semver.major(clientVersion) !== semver.major(serverVersion) || semver.minor(clientVersion) !== semver.minor(serverVersion)) { + return err(true, `YOUR VERSION OF STACK AUTH IS SEVERELY OUTDATED. YOU SHOULD UPDATE IT AS SOON AS POSSIBLE. WE CAN'T APPLY SECURITY UPDATES IF YOU DON'T UPDATE STACK AUTH REGULARLY. (your version is v${clientVersion}; the current version is v${serverVersion}).`); + } + if (semver.lt(clientVersion, serverVersion)) { + return err(false, `You are running an outdated version of Stack Auth (v${clientVersion}; the current version is v${serverVersion}). Please update to the latest version as soon as possible to ensure that you get the latest feature and security updates.`); + } + if (semver.gt(clientVersion, serverVersion)) { + return err(false, `You are running a version of Stack Auth that is newer than the newest known version (v${clientVersion} > v${serverVersion}). This is weird. Are you running on a development branch?`); + } + if (clientVersion !== serverVersion) { + return err(true, `You are running a version of Stack Auth that is not the same as the newest known version (v${clientVersion} !== v${serverVersion}). Please update to the latest version as soon as possible to ensure that you get the latest feature and security updates.`); + } + + return { + statusCode: 200, + bodyType: "json", + body: { + upToDate: true, + }, + }; + }, +}); diff --git a/apps/backend/src/app/api/v1/connected-accounts/[user_id]/[provider_id]/access-token/route.tsx b/apps/backend/src/app/api/v1/connected-accounts/[user_id]/[provider_id]/access-token/route.tsx index b8bc85e2a1..99cf78f58d 100644 --- a/apps/backend/src/app/api/v1/connected-accounts/[user_id]/[provider_id]/access-token/route.tsx +++ b/apps/backend/src/app/api/v1/connected-accounts/[user_id]/[provider_id]/access-token/route.tsx @@ -1,3 +1,3 @@ import { connectedAccountAccessTokenCrudHandlers } from "./crud"; -export const POST = connectedAccountAccessTokenCrudHandlers.createHandler; \ No newline at end of file +export const POST = connectedAccountAccessTokenCrudHandlers.createHandler; diff --git a/apps/backend/src/app/api/v1/contact-channels/[user_id]/[contact_channel_id]/route.tsx b/apps/backend/src/app/api/v1/contact-channels/[user_id]/[contact_channel_id]/route.tsx new file mode 100644 index 0000000000..285463a30e --- /dev/null +++ b/apps/backend/src/app/api/v1/contact-channels/[user_id]/[contact_channel_id]/route.tsx @@ -0,0 +1,5 @@ +import { contactChannelsCrudHandlers } from "../../crud"; + +export const GET = contactChannelsCrudHandlers.readHandler; +export const PATCH = contactChannelsCrudHandlers.updateHandler; +export const DELETE = contactChannelsCrudHandlers.deleteHandler; diff --git a/apps/backend/src/app/api/v1/contact-channels/[user_id]/[contact_channel_id]/send-verification-code/route.tsx b/apps/backend/src/app/api/v1/contact-channels/[user_id]/[contact_channel_id]/send-verification-code/route.tsx new file mode 100644 index 0000000000..a9256aa62a --- /dev/null +++ b/apps/backend/src/app/api/v1/contact-channels/[user_id]/[contact_channel_id]/send-verification-code/route.tsx @@ -0,0 +1,93 @@ +import { usersCrudHandlers } from "@/app/api/v1/users/crud"; +import { prismaClient } from "@/prisma-client"; +import { CrudHandlerInvocationError } from "@/route-handlers/crud-handler"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { KnownErrors } from "@stackframe/stack-shared"; +import { adaptSchema, clientOrHigherAuthTypeSchema, emailVerificationCallbackUrlSchema, userIdOrMeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; +import { contactChannelVerificationCodeHandler } from "../../../verify/verification-code-handler"; + +export const POST = createSmartRouteHandler({ + metadata: { + summary: "Send email verification code", + description: "Send a code to the user's email address for verifying the email.", + tags: ["Emails"], + }, + request: yupObject({ + params: yupObject({ + user_id: userIdOrMeSchema.required(), + contact_channel_id: yupString().required(), + }).required(), + auth: yupObject({ + type: clientOrHigherAuthTypeSchema, + project: adaptSchema.required(), + user: adaptSchema.optional(), + }).required(), + body: yupObject({ + callback_url: emailVerificationCallbackUrlSchema.required(), + }).required(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).required(), + bodyType: yupString().oneOf(["success"]).required(), + }), + async handler({ auth, body: { callback_url: callbackUrl }, params }) { + let user; + if (auth.type === "client") { + const currentUserId = auth.user?.id || throwErr(new KnownErrors.CannotGetOwnUserWithoutUser()); + if (currentUserId !== params.user_id) { + throw new StatusError(StatusError.BadRequest, "Can only send verification code for your own user"); + } + user = auth.user || throwErr("User not found"); + } else { + try { + user = await usersCrudHandlers.adminRead({ + project: auth.project, + user_id: params.user_id + }); + } catch (e) { + if (e instanceof CrudHandlerInvocationError && e.cause instanceof KnownErrors.UserNotFound) { + throw new KnownErrors.UserIdDoesNotExist(params.user_id); + } + throw e; + } + } + + const contactChannel = await prismaClient.contactChannel.findUnique({ + where: { + projectId_projectUserId_id: { + projectId: auth.project.id, + projectUserId: user.id, + id: params.contact_channel_id, + }, + type: "EMAIL", + }, + }); + + if (!contactChannel) { + throw new StatusError(StatusError.NotFound, "Contact channel not found"); + } + + if (contactChannel.isVerified) { + throw new KnownErrors.EmailAlreadyVerified(); + } + + await contactChannelVerificationCodeHandler.sendCode({ + project: auth.project, + data: { + user_id: user.id, + }, + method: { + email: contactChannel.value, + }, + callbackUrl, + }, { + user, + }); + + return { + statusCode: 200, + bodyType: "success", + }; + }, +}); diff --git a/apps/backend/src/app/api/v1/contact-channels/crud.tsx b/apps/backend/src/app/api/v1/contact-channels/crud.tsx new file mode 100644 index 0000000000..89da7b6e88 --- /dev/null +++ b/apps/backend/src/app/api/v1/contact-channels/crud.tsx @@ -0,0 +1,217 @@ +import { ensureContactChannelDoesNotExists, ensureContactChannelExists } from "@/lib/request-checks"; +import { prismaClient } from "@/prisma-client"; +import { createCrudHandlers } from "@/route-handlers/crud-handler"; +import { Prisma } from "@prisma/client"; +import { KnownErrors } from "@stackframe/stack-shared"; +import { contactChannelsCrud } from "@stackframe/stack-shared/dist/interface/crud/contact-channels"; +import { userIdOrMeSchema, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; +import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies"; +import { typedToLowercase, typedToUppercase } from "@stackframe/stack-shared/dist/utils/strings"; + +export const contactChannelToCrud = (channel: Prisma.ContactChannelGetPayload<{}>) => { + return { + user_id: channel.projectUserId, + id: channel.id, + type: typedToLowercase(channel.type), + value: channel.value, + is_primary: !!channel.isPrimary, + is_verified: channel.isVerified, + used_for_auth: !!channel.usedForAuth, + } as const; +}; + +export const contactChannelsCrudHandlers = createLazyProxy(() => createCrudHandlers(contactChannelsCrud, { + querySchema: yupObject({ + user_id: userIdOrMeSchema.optional(), + contact_channel_id: yupString().uuid().optional(), + }), + paramsSchema: yupObject({ + user_id: userIdOrMeSchema.required(), + contact_channel_id: yupString().uuid().required(), + }), + onRead: async ({ params, auth }) => { + if (auth.type === 'client') { + const currentUserId = auth.user?.id || throwErr(new KnownErrors.CannotGetOwnUserWithoutUser()); + if (currentUserId !== params.user_id) { + throw new StatusError(StatusError.Forbidden, 'Client can only read contact channels for their own user.'); + } + } + + const contactChannel = await prismaClient.contactChannel.findUnique({ + where: { + projectId_projectUserId_id: { + projectId: auth.project.id, + projectUserId: params.user_id, + id: params.contact_channel_id || throwErr("Missing contact channel id"), + }, + }, + }); + + if (!contactChannel) { + throw new StatusError(StatusError.NotFound, 'Contact channel not found.'); + } + + return contactChannelToCrud(contactChannel); + }, + onCreate: async ({ auth, data }) => { + if (auth.type === 'client') { + const currentUserId = auth.user?.id || throwErr(new KnownErrors.CannotGetOwnUserWithoutUser()); + if (currentUserId !== data.user_id) { + throw new StatusError(StatusError.Forbidden, 'Client can only create contact channels for their own user.'); + } + } + + const contactChannel = await prismaClient.$transaction(async (tx) => { + await ensureContactChannelDoesNotExists(tx, { + projectId: auth.project.id, + userId: data.user_id, + type: data.type, + value: data.value, + }); + + const createdContactChannel = await tx.contactChannel.create({ + data: { + projectId: auth.project.id, + projectUserId: data.user_id, + type: typedToUppercase(data.type), + value: data.value, + isVerified: data.is_verified ?? false, + usedForAuth: data.used_for_auth ? 'TRUE' : null, + }, + }); + + if (data.is_primary) { + // mark all other channels as not primary + await tx.contactChannel.updateMany({ + where: { + projectId: auth.project.id, + projectUserId: data.user_id, + }, + data: { + isPrimary: null, + }, + }); + + await tx.contactChannel.update({ + where: { + projectId_projectUserId_id: { + projectId: auth.project.id, + projectUserId: data.user_id, + id: createdContactChannel.id, + }, + }, + data: { + isPrimary: 'TRUE', + }, + }); + } + + return await tx.contactChannel.findUnique({ + where: { + projectId_projectUserId_id: { + projectId: auth.project.id, + projectUserId: data.user_id, + id: createdContactChannel.id, + }, + }, + }) || throwErr("Failed to create contact channel"); + }); + + return contactChannelToCrud(contactChannel); + }, + onUpdate: async ({ params, auth, data }) => { + if (auth.type === 'client') { + const currentUserId = auth.user?.id || throwErr(new KnownErrors.CannotGetOwnUserWithoutUser()); + if (currentUserId !== params.user_id) { + throw new StatusError(StatusError.Forbidden, 'Client can only update contact channels for their own user.'); + } + } + + const updatedContactChannel = await prismaClient.$transaction(async (tx) => { + await ensureContactChannelExists(tx, { + projectId: auth.project.id, + userId: params.user_id, + contactChannelId: params.contact_channel_id || throwErr("Missing contact channel id"), + }); + + if (data.is_primary) { + // mark all other channels as not primary + await tx.contactChannel.updateMany({ + where: { + projectId: auth.project.id, + projectUserId: params.user_id, + }, + data: { + isPrimary: null, + }, + }); + } + + return await tx.contactChannel.update({ + where: { + projectId_projectUserId_id: { + projectId: auth.project.id, + projectUserId: params.user_id, + id: params.contact_channel_id || throwErr("Missing contact channel id"), + }, + }, + data: { + value: data.value, + isVerified: data.is_verified ?? (data.value ? false : undefined), // if value is updated and is_verified is not provided, set to false + usedForAuth: data.used_for_auth !== undefined ? (data.used_for_auth ? 'TRUE' : null) : undefined, + isPrimary: data.is_primary !== undefined ? (data.is_primary ? 'TRUE' : null) : undefined, + }, + }); + }); + + return contactChannelToCrud(updatedContactChannel); + }, + onDelete: async ({ params, auth }) => { + if (auth.type === 'client') { + const currentUserId = auth.user?.id || throwErr(new KnownErrors.CannotGetOwnUserWithoutUser()); + if (currentUserId !== params.user_id) { + throw new StatusError(StatusError.Forbidden, 'Client can only delete contact channels for their own user.'); + } + } + + await prismaClient.$transaction(async (tx) => { + await ensureContactChannelExists(tx, { + projectId: auth.project.id, + userId: params.user_id, + contactChannelId: params.contact_channel_id || throwErr("Missing contact channel id"), + }); + + await tx.contactChannel.delete({ + where: { + projectId_projectUserId_id: { + projectId: auth.project.id, + projectUserId: params.user_id, + id: params.contact_channel_id || throwErr("Missing contact channel id"), + }, + }, + }); + }); + }, + onList: async ({ query, auth }) => { + if (auth.type === 'client') { + const currentUserId = auth.user?.id || throwErr(new KnownErrors.CannotGetOwnUserWithoutUser()); + if (currentUserId !== query.user_id) { + throw new StatusError(StatusError.Forbidden, 'Client can only list contact channels for their own user.'); + } + } + + const contactChannels = await prismaClient.contactChannel.findMany({ + where: { + projectId: auth.project.id, + projectUserId: query.user_id, + id: query.contact_channel_id, + }, + }); + + return { + items: contactChannels.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()).map(contactChannelToCrud), + is_paginated: false, + }; + } +})); diff --git a/apps/backend/src/app/api/v1/contact-channels/route.tsx b/apps/backend/src/app/api/v1/contact-channels/route.tsx new file mode 100644 index 0000000000..007bbf0ec6 --- /dev/null +++ b/apps/backend/src/app/api/v1/contact-channels/route.tsx @@ -0,0 +1,4 @@ +import { contactChannelsCrudHandlers } from "./crud"; + +export const POST = contactChannelsCrudHandlers.createHandler; +export const GET = contactChannelsCrudHandlers.listHandler; diff --git a/apps/backend/src/app/api/v1/contact-channels/send-verification-code/route.tsx b/apps/backend/src/app/api/v1/contact-channels/send-verification-code/route.tsx index 8064f13301..4e121920b9 100644 --- a/apps/backend/src/app/api/v1/contact-channels/send-verification-code/route.tsx +++ b/apps/backend/src/app/api/v1/contact-channels/send-verification-code/route.tsx @@ -3,11 +3,10 @@ import { KnownErrors } from "@stackframe/stack-shared"; import { adaptSchema, clientOrHigherAuthTypeSchema, emailVerificationCallbackUrlSchema, signInEmailSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { contactChannelVerificationCodeHandler } from "../verify/verification-code-handler"; +/* deprecated, use /contact-channels/[user_id]/[contact_channel_id]/send-verification-code instead */ export const POST = createSmartRouteHandler({ metadata: { - summary: "Send email verification code", - description: "Send a code to the user's email address for verifying the email.", - tags: ["Emails"], + hidden: true, }, request: yupObject({ auth: yupObject({ diff --git a/apps/backend/src/app/api/v1/email-templates/[type]/route.tsx b/apps/backend/src/app/api/v1/email-templates/[type]/route.tsx index 2813d86de3..97e11c30a8 100644 --- a/apps/backend/src/app/api/v1/email-templates/[type]/route.tsx +++ b/apps/backend/src/app/api/v1/email-templates/[type]/route.tsx @@ -2,4 +2,4 @@ import { emailTemplateCrudHandlers } from "../crud"; export const GET = emailTemplateCrudHandlers.readHandler; export const PATCH = emailTemplateCrudHandlers.updateHandler; -export const DELETE = emailTemplateCrudHandlers.deleteHandler; \ No newline at end of file +export const DELETE = emailTemplateCrudHandlers.deleteHandler; diff --git a/apps/backend/src/app/api/v1/email-templates/crud.tsx b/apps/backend/src/app/api/v1/email-templates/crud.tsx index 7ca3d8e17d..9d10f0e0c2 100644 --- a/apps/backend/src/app/api/v1/email-templates/crud.tsx +++ b/apps/backend/src/app/api/v1/email-templates/crud.tsx @@ -125,4 +125,4 @@ export const emailTemplateCrudHandlers = createLazyProxy(() => createCrudHandler is_paginated: false, }; } -})); \ No newline at end of file +})); diff --git a/apps/backend/src/app/api/v1/email-templates/route.tsx b/apps/backend/src/app/api/v1/email-templates/route.tsx index 178277b08b..f6ca2739dc 100644 --- a/apps/backend/src/app/api/v1/email-templates/route.tsx +++ b/apps/backend/src/app/api/v1/email-templates/route.tsx @@ -1,3 +1,3 @@ import { emailTemplateCrudHandlers } from "./crud"; -export const GET = emailTemplateCrudHandlers.listHandler; \ No newline at end of file +export const GET = emailTemplateCrudHandlers.listHandler; diff --git a/apps/backend/src/app/api/v1/internal/api-keys/[api_key_id]/route.tsx b/apps/backend/src/app/api/v1/internal/api-keys/[api_key_id]/route.tsx index 1149b99e55..b676b495fa 100644 --- a/apps/backend/src/app/api/v1/internal/api-keys/[api_key_id]/route.tsx +++ b/apps/backend/src/app/api/v1/internal/api-keys/[api_key_id]/route.tsx @@ -1,4 +1,4 @@ import { apiKeyCrudHandlers } from "../crud"; export const GET = apiKeyCrudHandlers.readHandler; -export const PATCH = apiKeyCrudHandlers.updateHandler; \ No newline at end of file +export const PATCH = apiKeyCrudHandlers.updateHandler; diff --git a/apps/backend/src/app/api/v1/internal/api-keys/route.tsx b/apps/backend/src/app/api/v1/internal/api-keys/route.tsx index 2c6e3528f6..1d3cb8c474 100644 --- a/apps/backend/src/app/api/v1/internal/api-keys/route.tsx +++ b/apps/backend/src/app/api/v1/internal/api-keys/route.tsx @@ -53,4 +53,4 @@ export const POST = createSmartRouteHandler({ } } as const; }, -}); \ No newline at end of file +}); diff --git a/apps/backend/src/app/api/v1/team-invitations/accept/verification-code-handler.tsx b/apps/backend/src/app/api/v1/team-invitations/accept/verification-code-handler.tsx index 96a18c19b2..2906aad516 100644 --- a/apps/backend/src/app/api/v1/team-invitations/accept/verification-code-handler.tsx +++ b/apps/backend/src/app/api/v1/team-invitations/accept/verification-code-handler.tsx @@ -46,7 +46,7 @@ export const teamInvitationCodeHandler = createVerificationCodeHandler({ team_display_name: yupString().required(), }).required(), }), - async send(codeObj, createOptions, sendOptions: { user: UsersCrud["Admin"]["Read"] }){ + async send(codeObj, createOptions, sendOptions){ const team = await teamsCrudHandlers.adminRead({ project: createOptions.project, team_id: createOptions.data.team_id, @@ -54,7 +54,7 @@ export const teamInvitationCodeHandler = createVerificationCodeHandler({ await sendEmailFromTemplate({ project: createOptions.project, - user: sendOptions.user, + user: null, email: createOptions.method.email, templateType: "team_invitation", extraVariables: { diff --git a/apps/backend/src/app/api/v1/team-invitations/send-code/route.tsx b/apps/backend/src/app/api/v1/team-invitations/send-code/route.tsx index 9002052f1b..6a5529f352 100644 --- a/apps/backend/src/app/api/v1/team-invitations/send-code/route.tsx +++ b/apps/backend/src/app/api/v1/team-invitations/send-code/route.tsx @@ -3,6 +3,7 @@ import { prismaClient } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { adaptSchema, clientOrHigherAuthTypeSchema, teamIdSchema, teamInvitationCallbackUrlSchema, teamInvitationEmailSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { teamInvitationCodeHandler } from "../accept/verification-code-handler"; +import { KnownErrors } from "@stackframe/stack-shared"; export const POST = createSmartRouteHandler({ metadata: { @@ -14,7 +15,7 @@ export const POST = createSmartRouteHandler({ auth: yupObject({ type: clientOrHigherAuthTypeSchema, project: adaptSchema.required(), - user: adaptSchema.required(), + user: adaptSchema.optional(), }).required(), body: yupObject({ team_id: teamIdSchema.required(), @@ -29,6 +30,8 @@ export const POST = createSmartRouteHandler({ async handler({ auth, body }) { await prismaClient.$transaction(async (tx) => { if (auth.type === "client") { + if (!auth.user) throw new KnownErrors.UserAuthenticationRequired(); + await ensureUserTeamPermissionExists(tx, { project: auth.project, userId: auth.user.id, @@ -49,13 +52,11 @@ export const POST = createSmartRouteHandler({ email: body.email, }, callbackUrl: body.callback_url, - }, { - user: auth.user, - }); + }, {}); return { statusCode: 200, bodyType: "success", }; }, -}); \ No newline at end of file +}); diff --git a/apps/backend/src/app/api/v1/team-member-profiles/[team_id]/[user_id]/route.tsx b/apps/backend/src/app/api/v1/team-member-profiles/[team_id]/[user_id]/route.tsx index 8370dd2b88..2ffad7ce31 100644 --- a/apps/backend/src/app/api/v1/team-member-profiles/[team_id]/[user_id]/route.tsx +++ b/apps/backend/src/app/api/v1/team-member-profiles/[team_id]/[user_id]/route.tsx @@ -1,4 +1,4 @@ import { teamMemberProfilesCrudHandlers } from "../../crud"; export const GET = teamMemberProfilesCrudHandlers.readHandler; -export const PATCH = teamMemberProfilesCrudHandlers.updateHandler; \ No newline at end of file +export const PATCH = teamMemberProfilesCrudHandlers.updateHandler; diff --git a/apps/backend/src/app/api/v1/team-member-profiles/crud.tsx b/apps/backend/src/app/api/v1/team-member-profiles/crud.tsx index 9a4bb6a9db..72ea618ff8 100644 --- a/apps/backend/src/app/api/v1/team-member-profiles/crud.tsx +++ b/apps/backend/src/app/api/v1/team-member-profiles/crud.tsx @@ -1,4 +1,4 @@ -import { ensureTeamExist, ensureTeamMembershipExists, ensureUserExist, ensureUserTeamPermissionExists } from "@/lib/request-checks"; +import { ensureTeamExists, ensureTeamMembershipExists, ensureUserExists, ensureUserTeamPermissionExists } from "@/lib/request-checks"; import { prismaClient } from "@/prisma-client"; import { createCrudHandlers } from "@/route-handlers/crud-handler"; import { Prisma } from "@prisma/client"; @@ -57,10 +57,10 @@ export const teamMemberProfilesCrudHandlers = createLazyProxy(() => createCrudHa } } else { if (query.team_id) { - await ensureTeamExist(tx, { projectId: auth.project.id, teamId: query.team_id }); + await ensureTeamExists(tx, { projectId: auth.project.id, teamId: query.team_id }); } if (query.user_id) { - await ensureUserExist(tx, { projectId: auth.project.id, userId: query.user_id }); + await ensureUserExists(tx, { projectId: auth.project.id, userId: query.user_id }); } } diff --git a/apps/backend/src/app/api/v1/team-member-profiles/route.tsx b/apps/backend/src/app/api/v1/team-member-profiles/route.tsx index 5f8d2a52fe..5ccb65c329 100644 --- a/apps/backend/src/app/api/v1/team-member-profiles/route.tsx +++ b/apps/backend/src/app/api/v1/team-member-profiles/route.tsx @@ -1,3 +1,3 @@ import { teamMemberProfilesCrudHandlers } from "./crud"; -export const GET = teamMemberProfilesCrudHandlers.listHandler; \ No newline at end of file +export const GET = teamMemberProfilesCrudHandlers.listHandler; diff --git a/apps/backend/src/app/api/v1/team-memberships/[team_id]/[user_id]/route.tsx b/apps/backend/src/app/api/v1/team-memberships/[team_id]/[user_id]/route.tsx index edfcbf4d50..daa39db969 100644 --- a/apps/backend/src/app/api/v1/team-memberships/[team_id]/[user_id]/route.tsx +++ b/apps/backend/src/app/api/v1/team-memberships/[team_id]/[user_id]/route.tsx @@ -1,4 +1,5 @@ import { teamMembershipsCrudHandlers } from "../../crud"; +// TODO: move this to /team-memberships export const POST = teamMembershipsCrudHandlers.createHandler; -export const DELETE = teamMembershipsCrudHandlers.deleteHandler; \ No newline at end of file +export const DELETE = teamMembershipsCrudHandlers.deleteHandler; diff --git a/apps/backend/src/app/api/v1/team-memberships/crud.tsx b/apps/backend/src/app/api/v1/team-memberships/crud.tsx index 60b4dc8295..9236f8585f 100644 --- a/apps/backend/src/app/api/v1/team-memberships/crud.tsx +++ b/apps/backend/src/app/api/v1/team-memberships/crud.tsx @@ -1,15 +1,16 @@ -import { ensureTeamExist, ensureTeamMembershipExists, ensureTeamMembershipDoesNotExist, ensureUserTeamPermissionExists } from "@/lib/request-checks"; import { isTeamSystemPermission, teamSystemPermissionStringToDBType } from "@/lib/permissions"; +import { ensureTeamExists, ensureTeamMembershipDoesNotExist, ensureTeamMembershipExists, ensureUserTeamPermissionExists } from "@/lib/request-checks"; +import { PrismaTransaction } from "@/lib/types"; +import { sendTeamMembershipCreatedWebhook, sendTeamMembershipDeletedWebhook } from "@/lib/webhooks"; import { prismaClient } from "@/prisma-client"; import { createCrudHandlers } from "@/route-handlers/crud-handler"; +import { KnownErrors } from "@stackframe/stack-shared"; +import { ProjectsCrud } from "@stackframe/stack-shared/dist/interface/crud/projects"; import { teamMembershipsCrud } from "@stackframe/stack-shared/dist/interface/crud/team-memberships"; import { userIdOrMeSchema, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { throwErr } from "@stackframe/stack-shared/dist/utils/errors"; -import { KnownErrors } from "@stackframe/stack-shared"; -import { ProjectsCrud } from "@stackframe/stack-shared/dist/interface/crud/projects"; -import { PrismaTransaction } from "@/lib/types"; import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies"; -import { sendTeamMembershipCreatedWebhook, sendTeamMembershipDeletedWebhook } from "@/lib/webhooks"; +import { waitUntil } from "@vercel/functions"; export async function addUserToTeam(tx: PrismaTransaction, options: { @@ -57,7 +58,7 @@ export const teamMembershipsCrudHandlers = createLazyProxy(() => createCrudHandl }), onCreate: async ({ auth, params }) => { await prismaClient.$transaction(async (tx) => { - await ensureTeamExist(tx, { + await ensureTeamExists(tx, { projectId: auth.project.id, teamId: params.team_id, }); @@ -94,10 +95,10 @@ export const teamMembershipsCrudHandlers = createLazyProxy(() => createCrudHandl user_id: params.user_id, }; - await sendTeamMembershipCreatedWebhook({ + waitUntil(sendTeamMembershipCreatedWebhook({ projectId: auth.project.id, data, - }); + })); return data; }, @@ -137,12 +138,12 @@ export const teamMembershipsCrudHandlers = createLazyProxy(() => createCrudHandl }); }); - await sendTeamMembershipDeletedWebhook({ + waitUntil(sendTeamMembershipDeletedWebhook({ projectId: auth.project.id, data: { team_id: params.team_id, user_id: params.user_id, }, - }); + })); }, })); diff --git a/apps/backend/src/app/api/v1/team-permission-definitions/[permission_id]/route.tsx b/apps/backend/src/app/api/v1/team-permission-definitions/[permission_id]/route.tsx index 0f9e277315..05b53fd694 100644 --- a/apps/backend/src/app/api/v1/team-permission-definitions/[permission_id]/route.tsx +++ b/apps/backend/src/app/api/v1/team-permission-definitions/[permission_id]/route.tsx @@ -1,4 +1,4 @@ import { teamPermissionDefinitionsCrudHandlers } from "../crud"; export const PATCH = teamPermissionDefinitionsCrudHandlers.updateHandler; -export const DELETE = teamPermissionDefinitionsCrudHandlers.deleteHandler; \ No newline at end of file +export const DELETE = teamPermissionDefinitionsCrudHandlers.deleteHandler; diff --git a/apps/backend/src/app/api/v1/team-permission-definitions/route.tsx b/apps/backend/src/app/api/v1/team-permission-definitions/route.tsx index a237b21e1a..9434ce32f0 100644 --- a/apps/backend/src/app/api/v1/team-permission-definitions/route.tsx +++ b/apps/backend/src/app/api/v1/team-permission-definitions/route.tsx @@ -1,4 +1,4 @@ import { teamPermissionDefinitionsCrudHandlers } from "./crud"; export const POST = teamPermissionDefinitionsCrudHandlers.createHandler; -export const GET = teamPermissionDefinitionsCrudHandlers.listHandler; \ No newline at end of file +export const GET = teamPermissionDefinitionsCrudHandlers.listHandler; diff --git a/apps/backend/src/app/api/v1/team-permissions/[team_id]/[user_id]/[permission_id]/route.tsx b/apps/backend/src/app/api/v1/team-permissions/[team_id]/[user_id]/[permission_id]/route.tsx index c9a1d89435..071e31ef90 100644 --- a/apps/backend/src/app/api/v1/team-permissions/[team_id]/[user_id]/[permission_id]/route.tsx +++ b/apps/backend/src/app/api/v1/team-permissions/[team_id]/[user_id]/[permission_id]/route.tsx @@ -1,4 +1,5 @@ import { teamPermissionsCrudHandlers } from "../../../crud"; +// TODO: move this to /team-permissions export const POST = teamPermissionsCrudHandlers.createHandler; -export const DELETE = teamPermissionsCrudHandlers.deleteHandler; \ No newline at end of file +export const DELETE = teamPermissionsCrudHandlers.deleteHandler; diff --git a/apps/backend/src/app/api/v1/team-permissions/route.tsx b/apps/backend/src/app/api/v1/team-permissions/route.tsx index 317460b53d..086d36bfb7 100644 --- a/apps/backend/src/app/api/v1/team-permissions/route.tsx +++ b/apps/backend/src/app/api/v1/team-permissions/route.tsx @@ -1,3 +1,3 @@ import { teamPermissionsCrudHandlers } from "./crud"; -export const GET = teamPermissionsCrudHandlers.listHandler; \ No newline at end of file +export const GET = teamPermissionsCrudHandlers.listHandler; diff --git a/apps/backend/src/app/api/v1/teams/[team_id]/route.tsx b/apps/backend/src/app/api/v1/teams/[team_id]/route.tsx index 65bd71b447..8013ac1b6b 100644 --- a/apps/backend/src/app/api/v1/teams/[team_id]/route.tsx +++ b/apps/backend/src/app/api/v1/teams/[team_id]/route.tsx @@ -2,4 +2,4 @@ import { teamsCrudHandlers } from "../crud"; export const GET = teamsCrudHandlers.readHandler; export const PATCH = teamsCrudHandlers.updateHandler; -export const DELETE = teamsCrudHandlers.deleteHandler; \ No newline at end of file +export const DELETE = teamsCrudHandlers.deleteHandler; diff --git a/apps/backend/src/app/api/v1/teams/crud.tsx b/apps/backend/src/app/api/v1/teams/crud.tsx index 90d7d58b3e..4410e461b6 100644 --- a/apps/backend/src/app/api/v1/teams/crud.tsx +++ b/apps/backend/src/app/api/v1/teams/crud.tsx @@ -1,4 +1,4 @@ -import { ensureTeamExist, ensureTeamMembershipExists, ensureUserTeamPermissionExists } from "@/lib/request-checks"; +import { ensureTeamExists, ensureTeamMembershipExists, ensureUserTeamPermissionExists } from "@/lib/request-checks"; import { sendTeamCreatedWebhook, sendTeamDeletedWebhook, sendTeamUpdatedWebhook } from "@/lib/webhooks"; import { prismaClient } from "@/prisma-client"; import { createCrudHandlers } from "@/route-handlers/crud-handler"; @@ -9,6 +9,7 @@ import { userIdOrMeSchema, yupObject, yupString } from "@stackframe/stack-shared import { validateBase64Image } from "@stackframe/stack-shared/dist/utils/base64"; import { StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies"; +import { waitUntil } from "@vercel/functions"; import { addUserToTeam } from "../team-memberships/crud"; @@ -92,10 +93,10 @@ export const teamsCrudHandlers = createLazyProxy(() => createCrudHandlers(teamsC const result = teamPrismaToCrud(db); - await sendTeamCreatedWebhook({ + waitUntil(sendTeamCreatedWebhook({ projectId: auth.project.id, data: result, - }); + })); return result; }, @@ -144,7 +145,7 @@ export const teamsCrudHandlers = createLazyProxy(() => createCrudHandlers(teamsC }); } - await ensureTeamExist(tx, { projectId: auth.project.id, teamId: params.team_id }); + await ensureTeamExists(tx, { projectId: auth.project.id, teamId: params.team_id }); return await tx.team.update({ where: { @@ -165,10 +166,10 @@ export const teamsCrudHandlers = createLazyProxy(() => createCrudHandlers(teamsC const result = teamPrismaToCrud(db); - await sendTeamUpdatedWebhook({ + waitUntil(sendTeamUpdatedWebhook({ projectId: auth.project.id, data: result, - }); + })); return result; }, @@ -184,7 +185,7 @@ export const teamsCrudHandlers = createLazyProxy(() => createCrudHandlers(teamsC recursive: true, }); } - await ensureTeamExist(tx, { projectId: auth.project.id, teamId: params.team_id }); + await ensureTeamExists(tx, { projectId: auth.project.id, teamId: params.team_id }); await tx.team.delete({ where: { @@ -196,12 +197,12 @@ export const teamsCrudHandlers = createLazyProxy(() => createCrudHandlers(teamsC }); }); - await sendTeamDeletedWebhook({ + waitUntil(sendTeamDeletedWebhook({ projectId: auth.project.id, data: { id: params.team_id, }, - }); + })); }, onList: async ({ query, auth }) => { if (auth.type === 'client') { diff --git a/apps/backend/src/app/api/v1/teams/route.tsx b/apps/backend/src/app/api/v1/teams/route.tsx index d7f31e08b4..357fd928b5 100644 --- a/apps/backend/src/app/api/v1/teams/route.tsx +++ b/apps/backend/src/app/api/v1/teams/route.tsx @@ -1,4 +1,4 @@ import { teamsCrudHandlers } from "./crud"; export const GET = teamsCrudHandlers.listHandler; -export const POST = teamsCrudHandlers.createHandler; \ No newline at end of file +export const POST = teamsCrudHandlers.createHandler; diff --git a/apps/backend/src/app/api/v1/users/crud.tsx b/apps/backend/src/app/api/v1/users/crud.tsx index 03511e79e3..4e94ede80f 100644 --- a/apps/backend/src/app/api/v1/users/crud.tsx +++ b/apps/backend/src/app/api/v1/users/crud.tsx @@ -1,4 +1,4 @@ -import { ensureTeamMembershipExists, ensureUserExist } from "@/lib/request-checks"; +import { ensureTeamMembershipExists, ensureUserExists } from "@/lib/request-checks"; import { PrismaTransaction } from "@/lib/types"; import { sendTeamMembershipDeletedWebhook, sendUserCreatedWebhook, sendUserDeletedWebhook, sendUserUpdatedWebhook } from "@/lib/webhooks"; import { prismaClient } from "@/prisma-client"; @@ -14,6 +14,7 @@ import { StackAssertionError, StatusError, throwErr } from "@stackframe/stack-sh import { hashPassword } from "@stackframe/stack-shared/dist/utils/password"; import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies"; import { typedToLowercase } from "@stackframe/stack-shared/dist/utils/strings"; +import { waitUntil } from '@vercel/functions'; import { teamPrismaToCrud, teamsCrudHandlers } from "../teams/crud"; export const userFullInclude = { @@ -40,22 +41,6 @@ export const userFullInclude = { }, } satisfies Prisma.ProjectUserInclude; -export const contactChannelToCrud = (channel: Prisma.ContactChannelGetPayload<{}>) => { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (channel.type !== 'EMAIL') { - throw new StackAssertionError("Only email channels are supported"); - } - - return { - id: channel.id, - type: 'email', - value: channel.value, - is_primary: !!channel.isPrimary, - is_verified: channel.isVerified, - used_for_auth: !!channel.usedForAuth, - }; -}; - export const oauthProviderConfigToCrud = ( config: Prisma.OAuthProviderConfigGetPayload<{ include: { proxiedOAuthConfig: true, @@ -95,13 +80,15 @@ export const userPrismaToCrud = ( id: prisma.projectUserId, display_name: prisma.displayName || null, primary_email: primaryEmailContactChannel?.value || null, - primary_email_verified: primaryEmailContactChannel?.isVerified || false, + primary_email_verified: !!primaryEmailContactChannel?.isVerified, + primary_email_auth_enabled: !!primaryEmailContactChannel?.usedForAuth, profile_image_url: prisma.profileImageUrl, signed_up_at_millis: prisma.createdAt.getTime(), client_metadata: prisma.clientMetadata, client_read_only_metadata: prisma.clientReadOnlyMetadata, server_metadata: prisma.serverMetadata, has_password: !!passwordAuth, + otp_auth_enabled: !!otpAuth, auth_with_email: !!passwordAuth || !!otpAuth, requires_totp_mfa: prisma.requiresTotpMfa, oauth_providers: prisma.projectUserOAuthAccounts.map((a) => ({ @@ -132,9 +119,6 @@ async function checkAuthData( if (!data.primaryEmail && data.primaryEmailVerified) { throw new StatusError(400, "primary_email_verified cannot be true without primary_email"); } - if (!data.primaryEmailAuthEnabled && data.passwordHash) { - throw new StatusError(400, "password cannot be set without primary_email_auth_enabled"); - } if (data.primaryEmailAuthEnabled) { if (!data.oldPrimaryEmail || data.oldPrimaryEmail !== data.primaryEmail) { const otpAuth = await tx.contactChannel.findFirst({ @@ -157,7 +141,10 @@ async function checkAuthData( async function getPasswordConfig(tx: PrismaTransaction, projectConfigId: string) { const passwordConfig = await tx.passwordAuthMethodConfig.findMany({ where: { - projectConfigId: projectConfigId + projectConfigId: projectConfigId, + authMethodConfig: { + enabled: true, + } }, include: { authMethodConfig: true, @@ -175,7 +162,10 @@ async function getPasswordConfig(tx: PrismaTransaction, projectConfigId: string) async function getOtpConfig(tx: PrismaTransaction, projectConfigId: string) { const otpConfig = await tx.otpAuthMethodConfig.findMany({ where: { - projectConfigId: projectConfigId + projectConfigId: projectConfigId, + authMethodConfig: { + enabled: true, + } }, include: { authMethodConfig: true, @@ -389,55 +379,57 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC projectUserId: newUser.projectUserId, projectId: auth.project.id, type: 'EMAIL' as const, - value: data.primary_email || throwErr("primary_email_auth_enabled is true but primary_email is not set"), + value: data.primary_email, isVerified: data.primary_email_verified ?? false, isPrimary: "TRUE", usedForAuth: data.primary_email_auth_enabled ? BooleanTrue.TRUE : null, } }); + } - if (data.primary_email_auth_enabled) { - const otpConfig = await getOtpConfig(tx, auth.project.config.id); + if (data.password) { + const passwordConfig = await getPasswordConfig(tx, auth.project.config.id); - if (otpConfig) { - await tx.authMethod.create({ - data: { - projectId: auth.project.id, + if (!passwordConfig) { + throw new StatusError(StatusError.BadRequest, "Password auth not enabled in the project"); + } + + await tx.authMethod.create({ + data: { + projectId: auth.project.id, + projectConfigId: auth.project.config.id, + projectUserId: newUser.projectUserId, + authMethodConfigId: passwordConfig.authMethodConfigId, + passwordAuthMethod: { + create: { + passwordHash: await hashPassword(data.password), projectUserId: newUser.projectUserId, - projectConfigId: auth.project.config.id, - authMethodConfigId: otpConfig.authMethodConfigId, - otpAuthMethod: { - create: { - projectUserId: newUser.projectUserId, - } - } } - }); + } } - } + }); + } - if (data.password) { - const passwordConfig = await getPasswordConfig(tx, auth.project.config.id); + if (data.otp_auth_enabled) { + const otpConfig = await getOtpConfig(tx, auth.project.config.id); - if (!passwordConfig) { - throw new StatusError(StatusError.BadRequest, "Password auth not enabled in the project"); - } + if (!otpConfig) { + throw new StatusError(StatusError.BadRequest, "OTP auth not enabled in the project"); + } - await tx.authMethod.create({ - data: { - projectId: auth.project.id, - projectConfigId: auth.project.config.id, - projectUserId: newUser.projectUserId, - authMethodConfigId: passwordConfig.authMethodConfigId, - passwordAuthMethod: { - create: { - passwordHash: await hashPassword(data.password), - projectUserId: newUser.projectUserId, - } + await tx.authMethod.create({ + data: { + projectId: auth.project.id, + projectConfigId: auth.project.config.id, + projectUserId: newUser.projectUserId, + authMethodConfigId: otpConfig.authMethodConfigId, + otpAuthMethod: { + create: { + projectUserId: newUser.projectUserId, } } - }); - } + } + }); } const user = await tx.projectUser.findUnique({ @@ -472,16 +464,16 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC }); } - await sendUserCreatedWebhook({ + waitUntil(sendUserCreatedWebhook({ projectId: auth.project.id, data: result, - }); + })); return result; }, onUpdate: async ({ auth, data, params }) => { const result = await prismaClient.$transaction(async (tx) => { - await ensureUserExist(tx, { projectId: auth.project.id, userId: params.user_id }); + await ensureUserExists(tx, { projectId: auth.project.id, userId: params.user_id }); if (data.selected_team_id !== undefined) { if (data.selected_team_id !== null) { @@ -542,7 +534,7 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC oldPrimaryEmail: primaryEmailContactChannel?.value, primaryEmail: primaryEmailContactChannel?.value || data.primary_email, primaryEmailVerified: primaryEmailContactChannel?.isVerified || data.primary_email_verified, - primaryEmailAuthEnabled: !!otpAuth || !!passwordAuth || data.primary_email_auth_enabled, + primaryEmailAuthEnabled: !!primaryEmailContactChannel?.usedForAuth || data.primary_email_auth_enabled, passwordHash: passwordAuth ? passwordAuth.passwordHash : (data.password && await hashPassword(data.password)), }); @@ -607,26 +599,13 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC }); } - // if primary email auth is true + // if otp_auth_enabled is true // - create a new otp auth method if it doesn't exist - // if primary email auth is false + // if otp_auth_enabled is false // - delete the otp auth method if it exists - if (data.primary_email_auth_enabled !== undefined) { - if (data.primary_email_auth_enabled) { + if (data.otp_auth_enabled !== undefined) { + if (data.otp_auth_enabled) { if (!otpAuth) { - const primaryEmailChannel = await tx.contactChannel.findFirst({ - where: { - projectId: auth.project.id, - projectUserId: params.user_id, - type: 'EMAIL', - isPrimary: "TRUE", - } - }); - - if (!primaryEmailChannel) { - throw new StackAssertionError("primary_email_auth_enabled is true but primary_email is not set"); - } - const otpConfig = await getOtpConfig(tx, auth.project.config.id); if (otpConfig) { @@ -759,16 +738,16 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC }); - await sendUserUpdatedWebhook({ + waitUntil(sendUserUpdatedWebhook({ projectId: auth.project.id, data: result, - }); + })); return result; }, onDelete: async ({ auth, params }) => { const { teams } = await prismaClient.$transaction(async (tx) => { - await ensureUserExist(tx, { projectId: auth.project.id, userId: params.user_id }); + await ensureUserExists(tx, { projectId: auth.project.id, userId: params.user_id }); const teams = await tx.team.findMany({ where: { @@ -797,15 +776,15 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC return { teams }; }); - await Promise.all(teams.map(t => sendTeamMembershipDeletedWebhook({ + waitUntil(Promise.all(teams.map(t => sendTeamMembershipDeletedWebhook({ projectId: auth.project.id, data: { team_id: t.teamId, user_id: params.user_id, }, - }))); + })))); - await sendUserDeletedWebhook({ + waitUntil(sendUserDeletedWebhook({ projectId: auth.project.id, data: { id: params.user_id, @@ -813,7 +792,7 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC id: t.teamId, })), }, - }); + })); } })); diff --git a/apps/backend/src/app/api/v1/webhooks/svix-token/route.tsx b/apps/backend/src/app/api/v1/webhooks/svix-token/route.tsx index e7e03b0771..61008d8429 100644 --- a/apps/backend/src/app/api/v1/webhooks/svix-token/route.tsx +++ b/apps/backend/src/app/api/v1/webhooks/svix-token/route.tsx @@ -18,4 +18,4 @@ const appPortalCrudHandlers = createLazyProxy(() => createCrudHandlers(svixToken }, })); -export const POST = appPortalCrudHandlers.createHandler; \ No newline at end of file +export const POST = appPortalCrudHandlers.createHandler; diff --git a/apps/backend/src/app/page.tsx b/apps/backend/src/app/page.tsx index 7f37d18c9f..942b29afc2 100644 --- a/apps/backend/src/app/page.tsx +++ b/apps/backend/src/app/page.tsx @@ -2,7 +2,7 @@ import Link from "next/link"; export default function Home() { return <> - Welcome to Stack's API endpoint.
+ Welcome to Stack Auth's API endpoint.

Were you looking for Stack's dashboard instead?

diff --git a/apps/backend/src/lib/contact-channel.tsx b/apps/backend/src/lib/contact-channel.tsx new file mode 100644 index 0000000000..d3e9c4626c --- /dev/null +++ b/apps/backend/src/lib/contact-channel.tsx @@ -0,0 +1,36 @@ +import { ContactChannelType } from "@prisma/client"; +import { PrismaTransaction } from "./types"; + +const fullContactChannelInclude = { + projectUser: { + include: { + authMethods: { + include: { + otpAuthMethod: true, + passwordAuthMethod: true, + } + } + } + } +}; + +export async function getAuthContactChannel( + tx: PrismaTransaction, + options: { + projectId: string, + type: ContactChannelType, + value: string, + } +) { + return await tx.contactChannel.findUnique({ + where: { + projectId_type_value_usedForAuth: { + projectId: options.projectId, + type: options.type, + value: options.value, + usedForAuth: "TRUE", + } + }, + include: fullContactChannelInclude, + }); +} diff --git a/apps/backend/src/lib/emails.tsx b/apps/backend/src/lib/emails.tsx index cdbcd88ebb..3dc5b870db 100644 --- a/apps/backend/src/lib/emails.tsx +++ b/apps/backend/src/lib/emails.tsx @@ -9,6 +9,8 @@ import { StackAssertionError } from '@stackframe/stack-shared/dist/utils/errors' import { filterUndefined } from '@stackframe/stack-shared/dist/utils/objects'; import { typedToUppercase } from '@stackframe/stack-shared/dist/utils/strings'; import nodemailer from 'nodemailer'; +import { trace } from '@opentelemetry/api'; +import { wait } from '@stackframe/stack-shared/dist/utils/promises'; export async function getEmailTemplate(projectId: string, type: keyof typeof EMAIL_TEMPLATES_METADATA) { const project = await getProject(projectId); @@ -74,28 +76,34 @@ export async function sendEmail({ html: string, text?: string, }) { - const transporter = nodemailer.createTransport({ - host: emailConfig.host, - port: emailConfig.port, - secure: emailConfig.secure, - auth: { - user: emailConfig.username, - pass: emailConfig.password, - }, - }); - - try { - return await transporter.sendMail({ - from: `"${emailConfig.senderName}" <${emailConfig.senderEmail}>`, - to, - subject, - text, - html - }); - } catch (error) { - throw new StackAssertionError('Failed to send email', { error, host: emailConfig.host, from: emailConfig.senderEmail, to, subject }); - } + await trace.getTracer('stackframe').startActiveSpan('sendEmail', async (span) => { + try { + const transporter = nodemailer.createTransport({ + logger: true, + host: emailConfig.host, + port: emailConfig.port, + secure: emailConfig.secure, + auth: { + user: emailConfig.username, + pass: emailConfig.password, + }, + }); + try { + return await transporter.sendMail({ + from: `"${emailConfig.senderName}" <${emailConfig.senderEmail}>`, + to, + subject, + text, + html + }); + } catch (error) { + throw new StackAssertionError('Failed to send email', { error, host: emailConfig.host, from: emailConfig.senderEmail, to, subject }); + } + } finally { + span.end(); + } + }); } export async function sendEmailFromTemplate(options: { diff --git a/apps/backend/src/lib/request-checks.tsx b/apps/backend/src/lib/request-checks.tsx index 4301a17bc2..d7cf8be1d0 100644 --- a/apps/backend/src/lib/request-checks.tsx +++ b/apps/backend/src/lib/request-checks.tsx @@ -4,7 +4,8 @@ import { ProjectsCrud } from "@stackframe/stack-shared/dist/interface/crud/proje import { ProviderType, sharedProviders, standardProviders } from "@stackframe/stack-shared/dist/utils/oauth"; import { listUserTeamPermissions } from "./permissions"; import { PrismaTransaction } from "./types"; -import { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/users"; +import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; +import { typedToUppercase } from "@stackframe/stack-shared/dist/utils/strings"; async function _getTeamMembership( @@ -33,7 +34,7 @@ export async function ensureTeamMembershipExists( userId: string, } ) { - await ensureUserExist(tx, { projectId: options.projectId, userId: options.userId }); + await ensureUserExists(tx, { projectId: options.projectId, userId: options.userId }); const member = await _getTeamMembership(tx, options); @@ -57,7 +58,7 @@ export async function ensureTeamMembershipDoesNotExist( } } -export async function ensureTeamExist( +export async function ensureTeamExists( tx: PrismaTransaction, options: { projectId: string, @@ -112,7 +113,7 @@ export async function ensureUserTeamPermissionExists( } } -export async function ensureUserExist( +export async function ensureUserExists( tx: PrismaTransaction, options: { projectId: string, @@ -149,4 +150,52 @@ export function ensureStandardProvider( throw new KnownErrors.InvalidStandardOAuthProviderId(providerId); } return providerId as any; -} \ No newline at end of file +} + +export async function ensureContactChannelDoesNotExists( + tx: PrismaTransaction, + options: { + projectId: string, + userId: string, + type: 'email', + value: string, + } +) { + const contactChannel = await tx.contactChannel.findUnique({ + where: { + projectId_projectUserId_type_value: { + projectId: options.projectId, + projectUserId: options.userId, + type: typedToUppercase(options.type), + value: options.value, + }, + }, + }); + + if (contactChannel) { + throw new StatusError(StatusError.BadRequest, 'Contact channel already exists'); + } +} + +export async function ensureContactChannelExists( + tx: PrismaTransaction, + options: { + projectId: string, + userId: string, + contactChannelId: string, + } +) { + const contactChannel = await tx.contactChannel.findUnique({ + where: { + projectId_projectUserId_id: { + projectId: options.projectId, + projectUserId: options.userId, + id: options.contactChannelId, + }, + }, + }); + + if (!contactChannel) { + throw new StatusError(StatusError.BadRequest, 'Contact channel not found'); + } +} diff --git a/apps/backend/src/lib/tokens.tsx b/apps/backend/src/lib/tokens.tsx index d40d3eacd6..9de00fa7a6 100644 --- a/apps/backend/src/lib/tokens.tsx +++ b/apps/backend/src/lib/tokens.tsx @@ -5,6 +5,7 @@ import { generateSecureRandomString } from '@stackframe/stack-shared/dist/utils/ import { getEnvVariable } from '@stackframe/stack-shared/dist/utils/env'; import { legacySignGlobalJWT, legacyVerifyGlobalJWT, signJWT, verifyJWT } from '@stackframe/stack-shared/dist/utils/jwt'; import { Result } from '@stackframe/stack-shared/dist/utils/results'; +import { waitUntil } from '@vercel/functions'; import * as jose from 'jose'; import { JOSEError, JWTExpired } from 'jose/errors'; import { SystemEventTypes, logEvent } from './events'; @@ -74,7 +75,7 @@ export async function generateAccessToken(options: { useLegacyGlobalJWT: boolean, userId: string, }) { - await logEvent([SystemEventTypes.UserActivity], { projectId: options.projectId, userId: options.userId }); + waitUntil(logEvent([SystemEventTypes.UserActivity], { projectId: options.projectId, userId: options.userId })); if (options.useLegacyGlobalJWT) { return await legacySignGlobalJWT( diff --git a/apps/backend/src/lib/types.tsx b/apps/backend/src/lib/types.tsx index d05d6c4529..bf161e89a3 100644 --- a/apps/backend/src/lib/types.tsx +++ b/apps/backend/src/lib/types.tsx @@ -1,3 +1,3 @@ import { PrismaClient } from "@prisma/client"; -export type PrismaTransaction = Parameters[0]>[0]; \ No newline at end of file +export type PrismaTransaction = Parameters[0]>[0]; diff --git a/apps/backend/src/lib/webhooks.tsx b/apps/backend/src/lib/webhooks.tsx index 1aad8d59b7..5a20655543 100644 --- a/apps/backend/src/lib/webhooks.tsx +++ b/apps/backend/src/lib/webhooks.tsx @@ -44,8 +44,6 @@ function createWebhookSender(event: WebhookEvent) { }; } -type x = yup.InferType; - export const sendUserCreatedWebhook = createWebhookSender(userCreatedWebhookEvent); export const sendUserUpdatedWebhook = createWebhookSender(userUpdatedWebhookEvent); export const sendUserDeletedWebhook = createWebhookSender(userDeletedWebhookEvent); diff --git a/apps/backend/src/oauth/providers/linkedin.tsx b/apps/backend/src/oauth/providers/linkedin.tsx index 1b93e7d0ed..80b2e89b80 100644 --- a/apps/backend/src/oauth/providers/linkedin.tsx +++ b/apps/backend/src/oauth/providers/linkedin.tsx @@ -40,4 +40,4 @@ export class LinkedInProvider extends OAuthBaseProvider { emailVerified: userInfo.email_verified, }); } -} \ No newline at end of file +} diff --git a/apps/backend/src/oauth/providers/microsoft.tsx b/apps/backend/src/oauth/providers/microsoft.tsx index 858ae53571..8ed79e0fe7 100644 --- a/apps/backend/src/oauth/providers/microsoft.tsx +++ b/apps/backend/src/oauth/providers/microsoft.tsx @@ -44,4 +44,4 @@ export class MicrosoftProvider extends OAuthBaseProvider { emailVerified: false, }); } -} \ No newline at end of file +} diff --git a/apps/backend/src/oauth/providers/x.tsx b/apps/backend/src/oauth/providers/x.tsx index 0359aa4784..4ab24f6e79 100644 --- a/apps/backend/src/oauth/providers/x.tsx +++ b/apps/backend/src/oauth/providers/x.tsx @@ -40,4 +40,4 @@ export class XProvider extends OAuthBaseProvider { emailVerified: false, }, { expectNoEmail: true }); } -} \ No newline at end of file +} diff --git a/apps/backend/src/oauth/utils.tsx b/apps/backend/src/oauth/utils.tsx index b0ac14ed86..a10eee9013 100644 --- a/apps/backend/src/oauth/utils.tsx +++ b/apps/backend/src/oauth/utils.tsx @@ -19,4 +19,4 @@ export function validateUserInfo( throw new Error("Email is required"); } return OAuthUserInfoSchema.validateSync(userInfo); -} \ No newline at end of file +} diff --git a/apps/dashboard/CHANGELOG.md b/apps/dashboard/CHANGELOG.md index a0401e569f..8f4aa14f7f 100644 --- a/apps/dashboard/CHANGELOG.md +++ b/apps/dashboard/CHANGELOG.md @@ -1,5 +1,94 @@ # @stackframe/stack-dashboard +## 2.6.11 + +### Patch Changes + +- fixed account settings bugs +- Updated dependencies + - @stackframe/stack-emails@2.6.11 + - @stackframe/stack-shared@2.6.11 + - @stackframe/stack-ui@2.6.11 + - @stackframe/stack@2.6.11 + +## 2.6.10 + +### Patch Changes + +- Various bugfixes +- Updated dependencies + - @stackframe/stack-emails@2.6.10 + - @stackframe/stack-shared@2.6.10 + - @stackframe/stack-ui@2.6.10 + - @stackframe/stack@2.6.10 + +## 2.6.9 + +### Patch Changes + +- - New contact channel API + - Fixed some visual gitches and typos + - Bug fixes +- Updated dependencies + - @stackframe/stack-emails@2.6.9 + - @stackframe/stack-shared@2.6.9 + - @stackframe/stack-ui@2.6.9 + - @stackframe/stack@2.6.9 + +## 2.6.8 + +### Patch Changes + +- Bugfixes +- Updated dependencies + - @stackframe/stack-shared@2.6.8 + - @stackframe/stack@2.6.8 + - @stackframe/stack-emails@2.6.8 + - @stackframe/stack-ui@2.6.8 + +## 2.6.7 + +### Patch Changes + +- Bugfixes +- Updated dependencies + - @stackframe/stack@2.6.7 + - @stackframe/stack-shared@2.6.7 + - @stackframe/stack-emails@2.6.7 + - @stackframe/stack-ui@2.6.7 + +## 2.6.6 + +### Patch Changes + +- Updated dependencies + - @stackframe/stack@2.6.6 + - @stackframe/stack-emails@2.6.6 + - @stackframe/stack-shared@2.6.6 + - @stackframe/stack-ui@2.6.6 + +## 2.6.5 + +### Patch Changes + +- Minor improvements +- Updated dependencies + - @stackframe/stack-emails@2.6.5 + - @stackframe/stack-shared@2.6.5 + - @stackframe/stack-ui@2.6.5 + - @stackframe/stack@2.6.5 + +## 2.6.4 + +### Patch Changes + +- fixed small problems +- Updated dependencies + - @stackframe/stack-emails@2.6.4 + - @stackframe/stack-shared@2.6.4 + - @stackframe/stack-ui@2.6.4 + - @stackframe/stack@2.6.4 + ## 2.6.3 ### Patch Changes diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 73ab92e961..a90ecd3a61 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -1,6 +1,6 @@ { "name": "@stackframe/stack-dashboard", - "version": "2.6.3", + "version": "2.6.11", "private": true, "scripts": { "clean": "rimraf .next && rimraf node_modules", @@ -39,7 +39,7 @@ "geist": "^1", "jose": "^5.2.2", "lucide-react": "^0.378.0", - "next": "^14.1", + "next": "^14.2.5", "next-runtime-env": "^3.2.2", "next-themes": "^0.2.1", "nodemailer": "^6.9.10", diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/layout.tsx b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/layout.tsx index 0fc70c1d60..58b0e9c060 100644 --- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/layout.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/layout.tsx @@ -7,4 +7,4 @@ export default function Page ({ children } : { children?: React.ReactNode }) { {children} ); -} \ No newline at end of file +} diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client.tsx index c5246540c7..a92c89f10f 100644 --- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client.tsx @@ -110,8 +110,8 @@ export default function PageClient () {
-
- {/* a transparent cover that prevents the card being clicked */} +
+ {/* a transparent cover that prevents the card from being clicked, even when pointer-events is overridden */}
; -} \ No newline at end of file +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/emails/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/emails/page-client.tsx index d8c0dcaa54..c20b9f1604 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/emails/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/emails/page-client.tsx @@ -39,7 +39,7 @@ export default function PageClient() {
- {emailConfig?.type === 'standard' ? emailConfig.senderEmail : 'noreply@stack-auth.com'} + {emailConfig?.type === 'standard' ? emailConfig.senderEmail : 'noreply@stackframe.co'} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/page-layout.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/page-layout.tsx index df7a5113d4..8a1b54da67 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/page-layout.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/page-layout.tsx @@ -28,4 +28,4 @@ export function PageLayout(props: {
); -} \ No newline at end of file +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/page.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/page.tsx index 88e4e5512b..ad33e5605f 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/page.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/page.tsx @@ -2,4 +2,4 @@ import { redirect } from "next/navigation"; export default function Page({ params }: { params: { projectId: string } }) { redirect(`/projects/${params.projectId}/users`); -} \ No newline at end of file +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx index fd135f17bc..3a0f80d4d4 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx @@ -1,5 +1,6 @@ 'use client'; +import { FeedbackDialog } from "@/components/feedback-dialog"; import { Link } from "@/components/link"; import { ProjectSwitcher } from "@/components/project-switcher"; import { cn } from "@/lib/utils"; @@ -37,7 +38,6 @@ import { useTheme } from "next-themes"; import { usePathname } from "next/navigation"; import { Fragment, useMemo, useState } from "react"; import { useAdminApp } from "./use-admin-app"; -import { FeedbackDialog } from "@/components/feedback-dialog"; type BreadcrumbItem = { item: React.ReactNode, href: string } @@ -340,7 +340,9 @@ function HeaderBreadcrumb({ - {selectedProject?.displayName} + + {selectedProject?.displayName} + {breadcrumbItems.map((name, index) => ( @@ -399,7 +401,7 @@ export default function SidebarLayout(props: { projectId: string, children?: Rea Feedback} /> - setTheme(resolvedTheme === 'light' ? 'dark' : 'light')} /> + setTheme(resolvedTheme === 'light' ? 'dark' : 'light')}/>
diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/teams/[teamId]/page.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/teams/[teamId]/page.tsx index 6395eb6a9c..7ea9011e28 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/teams/[teamId]/page.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/teams/[teamId]/page.tsx @@ -8,4 +8,4 @@ export default function Page({ params }: { params: { teamId: string } }) { return ( ); -} \ No newline at end of file +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/page-client.tsx index 2f9852a5f6..cad6a3a659 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/page-client.tsx @@ -4,10 +4,11 @@ import { UserTable } from "@/components/data-table/user-table"; import { FormDialog } from "@/components/form-dialog"; import { InputField, SwitchField } from "@/components/form-fields"; import { StyledLink } from "@/components/link"; -import { Alert, Button } from "@stackframe/stack-ui"; +import { Alert, Button, Label, Switch } from "@stackframe/stack-ui"; import * as yup from "yup"; import { PageLayout } from "../page-layout"; import { useAdminApp } from "../use-admin-app"; +import { useState } from "react"; function CreateDialog(props: { existingEmails: string[], @@ -16,11 +17,23 @@ function CreateDialog(props: { trigger?: React.ReactNode, }) { const adminApp = useAdminApp(); + const project = adminApp.useProject(); const formSchema = yup.object({ displayName: yup.string().optional(), primaryEmail: yup.string().email().notOneOf(props.existingEmails, "Email already exists").required(), - primaryEmailVerified: yup.boolean().optional(), - password: yup.string().required(), + primaryEmailVerified: yup.boolean().optional().test({ + name: 'otp-verified', + message: 'Primary email must be verified if OTP/magic link sign-in is enabled', + test: (value, context) => { + if (context.parent.otpAuthEnabled) { + return !!value; + } + return true; + }, + }).optional(), + password: yup.string().optional(), + otpAuthEnabled: yup.boolean().optional(), + passwordEnabled: yup.boolean().optional(), }); return { - await adminApp.createUser(values); + await adminApp.createUser({ + ...values, + primaryEmailAuthEnabled: true, + }); }} cancelButton render={(form) => ( <> - - -
-
- -
-
- -
-
- - + + + + {project.config.magicLinkEnabled && } + {project.config.credentialEnabled && } + {form.watch("passwordEnabled") ? : null} )} />; diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/webhooks/utils.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/webhooks/utils.tsx index f1ad358ef2..f38cfa9f04 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/webhooks/utils.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/webhooks/utils.tsx @@ -31,4 +31,4 @@ export function getSvixResult(data: { loaded: true, data: data.data, }; -} \ No newline at end of file +} diff --git a/apps/dashboard/src/app/(main)/handler/[...stack]/layout.tsx b/apps/dashboard/src/app/(main)/handler/[...stack]/layout.tsx index c27ffb2be0..a2530db071 100644 --- a/apps/dashboard/src/app/(main)/handler/[...stack]/layout.tsx +++ b/apps/dashboard/src/app/(main)/handler/[...stack]/layout.tsx @@ -9,4 +9,4 @@ export default function Page ({ children } : { children?: React.ReactNode }) {
); -} \ No newline at end of file +} diff --git a/apps/dashboard/src/app/(main)/page.tsx b/apps/dashboard/src/app/(main)/page.tsx index 4c2b10aeb5..1d3f0bdd33 100644 --- a/apps/dashboard/src/app/(main)/page.tsx +++ b/apps/dashboard/src/app/(main)/page.tsx @@ -2,4 +2,4 @@ import { redirect } from "next/navigation"; export default function Page() { redirect("projects"); -} \ No newline at end of file +} diff --git a/apps/dashboard/src/app/(main)/wizard-congrats/actions.tsx b/apps/dashboard/src/app/(main)/wizard-congrats/actions.tsx index 88562649bd..8034e646b6 100644 --- a/apps/dashboard/src/app/(main)/wizard-congrats/actions.tsx +++ b/apps/dashboard/src/app/(main)/wizard-congrats/actions.tsx @@ -15,4 +15,4 @@ export default function Actions() { ); -} \ No newline at end of file +} diff --git a/apps/dashboard/src/app/layout.tsx b/apps/dashboard/src/app/layout.tsx index 42081170ef..c431ca8185 100644 --- a/apps/dashboard/src/app/layout.tsx +++ b/apps/dashboard/src/app/layout.tsx @@ -20,6 +20,7 @@ import './globals.css'; import { CSPostHogProvider, UserIdentity } from './providers'; import dynamic from 'next/dynamic'; import { SpeedInsights } from "@vercel/speed-insights/next"; +import { VersionAlerter } from '../components/version-alerter'; export const metadata: Metadata = { metadataBase: new URL(env('NEXT_PUBLIC_STACK_URL') || ''), @@ -100,6 +101,7 @@ export default function RootLayout({ + {children} diff --git a/apps/dashboard/src/components/feedback-dialog.tsx b/apps/dashboard/src/components/feedback-dialog.tsx index 5ae44476f0..64f69adf86 100644 --- a/apps/dashboard/src/components/feedback-dialog.tsx +++ b/apps/dashboard/src/components/feedback-dialog.tsx @@ -56,4 +56,4 @@ export function FeedbackDialog(props: { }); }} />; -} \ No newline at end of file +} diff --git a/apps/dashboard/src/components/link.tsx b/apps/dashboard/src/components/link.tsx index e26bedabcf..86b9b91dcb 100644 --- a/apps/dashboard/src/components/link.tsx +++ b/apps/dashboard/src/components/link.tsx @@ -43,4 +43,4 @@ export function StyledLink(props: LinkProps) { {props.children} ); -} \ No newline at end of file +} diff --git a/apps/dashboard/src/components/project-card.tsx b/apps/dashboard/src/components/project-card.tsx index 24bb666ae1..ad78956d19 100644 --- a/apps/dashboard/src/components/project-card.tsx +++ b/apps/dashboard/src/components/project-card.tsx @@ -2,16 +2,16 @@ import { useRouter } from "@/components/router"; import { useFromNow } from '@/hooks/use-from-now'; import { AdminProject } from '@stackframe/stack'; -import { CardContent, CardDescription, CardFooter, CardHeader, CardTitle, ClickableCard, Typography } from '@stackframe/stack-ui'; +import { CardDescription, CardFooter, CardHeader, CardTitle, ClickableCard, Typography } from '@stackframe/stack-ui'; export function ProjectCard({ project }: { project: AdminProject }) { const createdAt = useFromNow(project.createdAt); const router = useRouter(); return ( - router.push(`/projects/${project.id}`)}> + router.push(`/projects/${project.id}`)}> - {project.displayName} + {project.displayName} {project.description} diff --git a/apps/dashboard/src/components/project-switcher.tsx b/apps/dashboard/src/components/project-switcher.tsx index d1fab6d0ed..6de6024466 100644 --- a/apps/dashboard/src/components/project-switcher.tsx +++ b/apps/dashboard/src/components/project-switcher.tsx @@ -3,11 +3,11 @@ import { useRouter } from "@/components/router"; import { useUser } from "@stackframe/stack"; import { Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@stackframe/stack-ui"; import { PlusIcon } from "lucide-react"; -import { useMemo } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; export function ProjectAvatar(props: { displayName: string }) { return ( -
+

{props.displayName.slice(0,1).toUpperCase()}

@@ -28,12 +28,12 @@ export function ProjectSwitcher(props: { currentProjectId: string }) { return ( setEditingValue(e.target.value)} - /> - - - - ) : ( - <> - {props.value} - - - )} -
- ); -} \ No newline at end of file diff --git a/packages/stack-ui/src/components/ui/button.tsx b/packages/stack-ui/src/components/ui/button.tsx index 1369bf4bfa..40ef43154b 100644 --- a/packages/stack-ui/src/components/ui/button.tsx +++ b/packages/stack-ui/src/components/ui/button.tsx @@ -13,13 +13,13 @@ const buttonVariants = cva( variants: { variant: { default: - "bg-primary text-primary-foreground shadow hover:bg-primary/90", + "bg-primary text-primary-foreground hover:bg-primary/90", destructive: - "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", + "bg-destructive text-destructive-foreground hover:bg-destructive/90", outline: - "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", + "border border-input bg-background hover:bg-accent hover:text-accent-foreground", secondary: - "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", + "bg-secondary text-secondary-foreground hover:bg-secondary/80", ghost: "hover:bg-accent hover:text-accent-foreground", link: "text-primary underline-offset-4 hover:underline", }, diff --git a/packages/stack-ui/src/components/ui/inline-code.tsx b/packages/stack-ui/src/components/ui/inline-code.tsx index 7e4f73b3e3..238829c622 100644 --- a/packages/stack-ui/src/components/ui/inline-code.tsx +++ b/packages/stack-ui/src/components/ui/inline-code.tsx @@ -36,4 +36,4 @@ const InlineCode = React.forwardRef< }); InlineCode.displayName = "Code"; -export { InlineCode }; \ No newline at end of file +export { InlineCode }; diff --git a/packages/stack-ui/src/components/ui/input.tsx b/packages/stack-ui/src/components/ui/input.tsx index d0ed5eacf2..85b76050f8 100644 --- a/packages/stack-ui/src/components/ui/input.tsx +++ b/packages/stack-ui/src/components/ui/input.tsx @@ -11,7 +11,7 @@ const Input = React.forwardRef( ; } ); -DelayedInput.displayName = "DelayedInput"; \ No newline at end of file +DelayedInput.displayName = "DelayedInput"; diff --git a/packages/stack-ui/src/components/ui/password-input.tsx b/packages/stack-ui/src/components/ui/password-input.tsx index 3db5dec02e..7f3db878a9 100644 --- a/packages/stack-ui/src/components/ui/password-input.tsx +++ b/packages/stack-ui/src/components/ui/password-input.tsx @@ -46,4 +46,4 @@ const PasswordInput = forwardRef( ); PasswordInput.displayName = "PasswordInput"; -export { PasswordInput }; \ No newline at end of file +export { PasswordInput }; diff --git a/packages/stack-ui/src/components/ui/separator.tsx b/packages/stack-ui/src/components/ui/separator.tsx index 5b2b2dd2eb..902cd4425b 100644 --- a/packages/stack-ui/src/components/ui/separator.tsx +++ b/packages/stack-ui/src/components/ui/separator.tsx @@ -28,4 +28,4 @@ const Separator = React.forwardRef< ); Separator.displayName = SeparatorPrimitive.Root.displayName; -export { Separator }; \ No newline at end of file +export { Separator }; diff --git a/packages/stack-ui/src/components/ui/typography.tsx b/packages/stack-ui/src/components/ui/typography.tsx index 34b8b8cc3f..6eeddeb86f 100644 --- a/packages/stack-ui/src/components/ui/typography.tsx +++ b/packages/stack-ui/src/components/ui/typography.tsx @@ -44,4 +44,4 @@ const Typography = React.forwardRef( ); Typography.displayName = "Typography"; -export { Typography }; \ No newline at end of file +export { Typography }; diff --git a/packages/stack-ui/src/index.ts b/packages/stack-ui/src/index.ts index c09c3f1dda..107b22c41b 100644 --- a/packages/stack-ui/src/index.ts +++ b/packages/stack-ui/src/index.ts @@ -4,7 +4,6 @@ export * from "./components/browser-frame"; export * from "./components/copy-button"; export * from "./components/copy-field"; export * from "./components/data-table"; -export * from "./components/editable-text"; export * from "./components/simple-tooltip"; export * from "./components/ui/accordion"; export * from "./components/ui/alert"; diff --git a/packages/stack-ui/src/lib/utils.tsx b/packages/stack-ui/src/lib/utils.tsx index 373983e34a..365058cebd 100644 --- a/packages/stack-ui/src/lib/utils.tsx +++ b/packages/stack-ui/src/lib/utils.tsx @@ -3,4 +3,4 @@ import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); -} \ No newline at end of file +} diff --git a/packages/stack/CHANGELOG.md b/packages/stack/CHANGELOG.md index 39645b3971..d6b0f86b4d 100644 --- a/packages/stack/CHANGELOG.md +++ b/packages/stack/CHANGELOG.md @@ -1,5 +1,85 @@ # @stackframe/stack +## 2.6.11 + +### Patch Changes + +- fixed account settings bugs +- Updated dependencies + - @stackframe/stack-shared@2.6.11 + - @stackframe/stack-sc@2.6.11 + - @stackframe/stack-ui@2.6.11 + +## 2.6.10 + +### Patch Changes + +- Various bugfixes +- Updated dependencies + - @stackframe/stack-shared@2.6.10 + - @stackframe/stack-sc@2.6.10 + - @stackframe/stack-ui@2.6.10 + +## 2.6.9 + +### Patch Changes + +- - New contact channel API + - Fixed some visual gitches and typos + - Bug fixes +- Updated dependencies + - @stackframe/stack-shared@2.6.9 + - @stackframe/stack-sc@2.6.9 + - @stackframe/stack-ui@2.6.9 + +## 2.6.8 + +### Patch Changes + +- Updated dependencies + - @stackframe/stack-shared@2.6.8 + - @stackframe/stack-ui@2.6.8 + - @stackframe/stack-sc@2.6.8 + +## 2.6.7 + +### Patch Changes + +- Bugfixes +- Updated dependencies + - @stackframe/stack-sc@2.6.7 + - @stackframe/stack-shared@2.6.7 + - @stackframe/stack-ui@2.6.7 + +## 2.6.6 + +### Patch Changes + +- Bugfixes + - @stackframe/stack-sc@2.6.6 + - @stackframe/stack-shared@2.6.6 + - @stackframe/stack-ui@2.6.6 + +## 2.6.5 + +### Patch Changes + +- Minor improvements +- Updated dependencies + - @stackframe/stack-shared@2.6.5 + - @stackframe/stack-sc@2.6.5 + - @stackframe/stack-ui@2.6.5 + +## 2.6.4 + +### Patch Changes + +- fixed small problems +- Updated dependencies + - @stackframe/stack-shared@2.6.4 + - @stackframe/stack-sc@2.6.4 + - @stackframe/stack-ui@2.6.4 + ## 2.6.3 ### Patch Changes diff --git a/packages/stack/package.json b/packages/stack/package.json index 25cee1c528..16519e9129 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -1,6 +1,6 @@ { "name": "@stackframe/stack", - "version": "2.6.3", + "version": "2.6.11", "sideEffects": false, "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/stack/scripts/merge-quetzal-translations.ts b/packages/stack/scripts/merge-quetzal-translations.ts index f83322b496..bb7daafeda 100644 --- a/packages/stack/scripts/merge-quetzal-translations.ts +++ b/packages/stack/scripts/merge-quetzal-translations.ts @@ -41,7 +41,7 @@ async function main() { ${JSON.stringify(locale)}: new Map(typedEntries(${JSON.stringify(sortKeys(localesByKeys[locale]), null, 2)} as const)), `).join("\n")} } as const)); - `); + ` + "\n"); } main().catch(err => { diff --git a/packages/stack/scripts/process-css.ts b/packages/stack/scripts/process-css.ts index 39831fa708..71716e80db 100644 --- a/packages/stack/scripts/process-css.ts +++ b/packages/stack/scripts/process-css.ts @@ -1,9 +1,9 @@ +import { replaceAll } from '@stackframe/stack-shared/dist/utils/strings'; +import autoprefixer from 'autoprefixer'; import * as fs from 'fs'; import * as path from 'path'; import postcss from 'postcss'; -import autoprefixer from 'autoprefixer'; import postcssNested from 'postcss-nested'; -import { replaceAll } from '@stackframe/stack-shared/dist/utils/strings'; const sentinel = '--stack-sentinel--'; const scopeName = 'stack-scope' @@ -48,7 +48,7 @@ async function main() { fs.writeFileSync(outputPath.replace(/\.ts$/, '.css'), content); content = JSON.stringify(content); - content = "export const globalCSS = " + content + ";"; + content = "export const globalCSS = " + content + ";\n"; fs.writeFileSync(outputPath, content); diff --git a/packages/stack/src/components-page/account-settings.tsx b/packages/stack/src/components-page/account-settings.tsx index abec29aecd..5fdd1a3a61 100644 --- a/packages/stack/src/components-page/account-settings.tsx +++ b/packages/stack/src/components-page/account-settings.tsx @@ -7,8 +7,8 @@ import { yupObject, yupString } from '@stackframe/stack-shared/dist/schema-field import { generateRandomValues } from '@stackframe/stack-shared/dist/utils/crypto'; import { throwErr } from '@stackframe/stack-shared/dist/utils/errors'; import { runAsynchronously, runAsynchronouslyWithAlert } from '@stackframe/stack-shared/dist/utils/promises'; -import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, Button, EditableText, Input, Label, PasswordInput, Separator, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Typography } from '@stackframe/stack-ui'; -import { CirclePlus, Contact, LucideIcon, Settings, ShieldCheck } from 'lucide-react'; +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, ActionCell, Badge, Button, Input, Label, PasswordInput, Separator, Switch, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Typography } from '@stackframe/stack-ui'; +import { CirclePlus, Contact, Edit, LucideIcon, Settings, ShieldCheck } from 'lucide-react'; import { useRouter } from "next/navigation"; import { TOTPController, createTOTPKeyURI } from "oslo/otp"; import * as QRCode from 'qrcode'; @@ -31,7 +31,7 @@ export function AccountSettings(props: { title: string, icon: LucideIcon, content: React.ReactNode, - subpath: string, + id: string, }[], }) { const { t } = useTranslation(); @@ -42,34 +42,34 @@ export function AccountSettings(props: { return ( -
+
, }, { - title: t('Security'), + title: t('Emails & Auth'), type: 'item', - subpath: '/security', + id: 'auth', icon: ShieldCheck, - content: , + content: , }, { title: t('Settings'), type: 'item', - subpath: '/settings', + id: 'settings', icon: Settings, content: , }, ...(props.extraItems?.map(item => ({ title: item.title, type: 'item', - subpath: item.subpath, + id: item.id, icon: item.icon, content: item.content, } as const)) || []), @@ -83,19 +83,18 @@ export function AccountSettings(props: { {team.displayName}
, type: 'item', - subpath: `/teams/${team.id}`, + id: `team-${team.id}`, content: , } as const)), ...project.config.clientTeamCreationEnabled ? [{ title: t('Create a team'), icon: CirclePlus, type: 'item', - subpath: '/team-creation', + id: 'team-creation', content: , }] as const : [], ] as const).filter((p) => p.type === 'divider' || (p as any).content )} title={t("Account Settings")} - basePath={stackApp.urls.accountSettings} />
@@ -152,6 +151,7 @@ function ProfilePage() { await user.update({ displayName: newDisplayName }); }}/> +
(null); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + const isLastEmail = contactChannels.filter(x => x.usedForAuth && x.type === 'email').length === 1; + + useEffect(() => { + if (addedEmail) { + runAsynchronously(async () => { + const cc = contactChannels.find(x => x.value === addedEmail); + if (cc && !cc.isVerified) { + await cc.sendVerificationEmail(); + } + setAddedEmail(null); + }); + } + }, [contactChannels, addedEmail]); + + const emailSchema = yupObject({ + email: yupString() + .email(t('Please enter a valid email address')) + .notOneOf(contactChannels.map(x => x.value), t('Email already exists')) + .required(t('Email is required')), + }); + + const { register, handleSubmit, formState: { errors }, reset } = useForm({ + resolver: yupResolver(emailSchema) + }); + + const onSubmit = async (data: yup.InferType) => { + setAddingEmailLoading(true); + try { + await user.createContactChannel({ type: 'email', value: data.email, usedForAuth: false }); + setAddedEmail(data.email); + } finally { + setAddingEmailLoading(false); + } + setAddingEmail(false); + reset(); + }; return ( - - {emailVerificationSection} - {passwordSection} - {mfaSection} - +
+
+ {t("Emails")} + {addingEmail ? ( +
{ + e.preventDefault(); + runAsynchronously(handleSubmit(onSubmit)); + }} + className='flex flex-col' + > +
+ + + +
+ {errors.email && } + + ) : ( +
+ +
+ )} +
+ + {contactChannels.length > 0 ? ( +
+ + + {/*eslint-disable-next-line @typescript-eslint/no-unnecessary-condition*/} + {contactChannels.filter(x => x.type === 'email') + .sort((a, b) => { + if (a.isPrimary !== b.isPrimary) return a.isPrimary ? -1 : 1; + if (a.isVerified !== b.isVerified) return a.isVerified ? -1 : 1; + return 0; + }) + .map(x => ( + + +
+ {x.value} +
+ {x.isPrimary ? {t("Primary")} : null} + {!x.isVerified ? {t("Unverified")} : null} + {x.usedForAuth ? {t("Used for sign-in")} : null} +
+
+
+ + { await x.sendVerificationEmail(); }, + }] : []), + ...(!x.isPrimary && x.isVerified ? [{ + item: t("Set as primary"), + onClick: async () => { await x.update({ isPrimary: true }); }, + }] : + !x.isPrimary ? [{ + item: t("Set as primary"), + onClick: async () => {}, + disabled: true, + disabledTooltip: t("Please verify your email first"), + }] : []), + ...(!x.usedForAuth && x.isVerified ? [{ + item: t("Use for sign-in"), + onClick: async () => { await x.update({ usedForAuth: true }); }, + }] : []), + ...(x.usedForAuth && !isLastEmail ? [{ + item: t("Stop using for sign-in"), + onClick: async () => { await x.update({ usedForAuth: false }); }, + }] : x.usedForAuth ? [{ + item: t("Stop using for sign-in"), + onClick: async () => {}, + disabled: true, + disabledTooltip: t("You can not remove your last sign-in email"), + }] : []), + ...(!isLastEmail || !x.usedForAuth ? [{ + item: t("Remove"), + onClick: async () => { await x.delete(); }, + danger: true, + }] : [{ + item: t("Remove"), + onClick: async () => {}, + disabled: true, + disabledTooltip: t("You can not remove your last sign-in email"), + }]), + ]}/> + +
+ ))} +
+
+
+ ) : null} +
); } -function SettingsPage() { - const deleteAccountSection = useDeleteAccountSection(); - const signOutSection = useSignOutSection(); +function EmailsAndAuthPage() { + const passwordSection = usePasswordSection(); + const mfaSection = useMfaSection(); + const otpSection = useOtpSection(); return ( - {deleteAccountSection} - {signOutSection} + + {passwordSection} + {otpSection} + {mfaSection} ); } -function useEmailVerificationSection() { +function useOtpSection() { const { t } = useTranslation(); - const user = useUser({ or: 'redirect' }); - const [emailSent, setEmailSent] = useState(false); + const user = useUser({ or: "throw" }); + const project = useStackApp().useProject(); + const contactChannels = user.useContactChannels(); + const isLastAuth = user.otpAuthEnabled && !user.hasPassword && user.oauthProviders.length === 0; + const [disabling, setDisabling] = useState(false); + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + const hasValidEmail = contactChannels.filter(x => x.type === 'email' && x.isVerified && x.usedForAuth).length > 0; - if (!user.primaryEmail) { + if (!project.config.magicLinkEnabled) { return null; } + const handleDisableOTP = async () => { + await user.update({ otpAuthEnabled: false }); + setDisabling(false); + }; + return ( -
-
- {user.primaryEmailVerified ? ( - {t("Your email has been verified.")} - ) : ( -
+
+
+ {hasValidEmail ? ( + user.otpAuthEnabled ? ( + !isLastAuth ? ( + !disabling ? ( + + ) : ( +
+ + {t("Are you sure you want to disable OTP sign-in? You will not be able to sign in with only emails anymore.")} + +
+ + +
+
+ ) + ) : ( + {t("OTP sign-in is enabled and cannot be disabled as it is currently the only sign-in method")} + ) + ) : ( -
+ ) + ) : ( + {t("To enable OTP sign-in, please add a verified email and set it as your sign-in email.")} )}
); } +function SettingsPage() { + const deleteAccountSection = useDeleteAccountSection(); + const signOutSection = useSignOutSection(); + + return ( + + {deleteAccountSection} + {signOutSection} + + ); +} + function usePasswordSection() { const { t } = useTranslation(); + const user = useUser({ or: "throw" }); + const contactChannels = user.useContactChannels(); + const [changingPassword, setChangingPassword] = useState(false); + const [loading, setLoading] = useState(false); const passwordSchema = yupObject({ - oldPassword: yupString().required(t('Please enter your old password')), + oldPassword: user.hasPassword ? yupString().required(t('Please enter your old password')) : yupString(), newPassword: yupString().required(t('Please enter your password')).test({ name: 'is-valid-password', test: (value, ctx) => { @@ -247,24 +450,25 @@ function usePasswordSection() { newPasswordRepeat: yupString().nullable().oneOf([yup.ref('newPassword'), "", null], t('Passwords do not match')).required(t('Please repeat your password')) }); - const user = useUser({ or: "throw" }); - const [changingPassword, setChangingPassword] = useState(false); const { register, handleSubmit, setError, formState: { errors }, clearErrors, reset } = useForm({ resolver: yupResolver(passwordSchema) }); - const [alreadyReset, setAlreadyReset] = useState(false); - const [loading, setLoading] = useState(false); + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + const hasValidEmail = contactChannels.filter(x => x.type === 'email' && x.isVerified && x.usedForAuth).length > 0; const onSubmit = async (data: yup.InferType) => { setLoading(true); try { const { oldPassword, newPassword } = data; - const error = await user.updatePassword({ oldPassword, newPassword }); + const error = user.hasPassword + ? await user.updatePassword({ oldPassword: oldPassword!, newPassword }) + : await user.setPassword({ password: newPassword! }); if (error) { setError('oldPassword', { type: 'manual', message: t('Incorrect password') }); } else { reset(); - setAlreadyReset(true); + setChangingPassword(false); } } finally { setLoading(false); @@ -274,28 +478,30 @@ function usePasswordSection() { const registerPassword = register('newPassword'); const registerPasswordRepeat = register('newPasswordRepeat'); - if (!user.hasPassword) { - return null; - } - return ( -
- -
- { - alreadyReset ? - {t("Password changed successfully!")} : - !changingPassword ? - : -
runAsynchronouslyWithAlert(handleSubmit(onSubmit)(e))} - noValidate - > +
+
+ {!changingPassword ? ( + hasValidEmail ? ( + + ) : ( + {t("To set a password, please add a verified email and set it as your sign-in email.")} + ) + ) : ( + runAsynchronouslyWithAlert(handleSubmit(onSubmit)(e))} + noValidate + > + {user.hasPassword && ( + <> + + )} - - { - clearErrors('newPassword'); - clearErrors('newPasswordRepeat'); - runAsynchronously(registerPassword.onChange(e)); - }} - /> - - - - { - clearErrors('newPassword'); - clearErrors('newPasswordRepeat'); - runAsynchronously(registerPasswordRepeat.onChange(e)); - }} - /> - + + { + clearErrors('newPassword'); + clearErrors('newPasswordRepeat'); + runAsynchronously(registerPassword.onChange(e)); + }} + /> + - - - } + + { + clearErrors('newPassword'); + clearErrors('newPasswordRepeat'); + runAsynchronously(registerPasswordRepeat.onChange(e)); + }} + /> + + +
+ + +
+ + )}
-
+
); } @@ -367,7 +588,7 @@ function useMfaSection() { return (
- {t("Disable")} + {t("Disable MFA")} ) : !generatedSecret && ( )}
@@ -729,7 +950,7 @@ export function TeamCreation() { setLoading(false); } - router.push(app.urls.accountSettings + `/teams/${team.id}`); + router.push(`#team-${team.id}`); }; return ( @@ -814,4 +1035,48 @@ export function useDeleteAccountSection() { ); -} \ No newline at end of file +} + +export function EditableText(props: { value: string, onSave?: (value: string) => void | Promise }) { + const [editing, setEditing] = useState(false); + const [editingValue, setEditingValue] = useState(props.value); + const { t } = useTranslation(); + + return ( +
+ {editing ? ( + <> + setEditingValue(e.target.value)} + /> + + + + ) : ( + <> + {props.value} + + + )} +
+ ); +} diff --git a/packages/stack/src/components-page/auth-page.tsx b/packages/stack/src/components-page/auth-page.tsx index a47645a5be..214f809eef 100644 --- a/packages/stack/src/components-page/auth-page.tsx +++ b/packages/stack/src/components-page/auth-page.tsx @@ -57,7 +57,7 @@ export function AuthPage(props: { return ( -
+
{props.type === 'sign-in' ? t("Sign in to your account") : t("Create a new account")} @@ -112,4 +112,4 @@ export function AuthPage(props: {
); -} \ No newline at end of file +} diff --git a/packages/stack/src/components-page/forgot-password.tsx b/packages/stack/src/components-page/forgot-password.tsx index 4cf1ac976d..82561a883e 100644 --- a/packages/stack/src/components-page/forgot-password.tsx +++ b/packages/stack/src/components-page/forgot-password.tsx @@ -77,7 +77,7 @@ export function ForgotPassword(props: { fullPage?: boolean }) { return (
diff --git a/packages/stack/src/components-page/oauth-callback.tsx b/packages/stack/src/components-page/oauth-callback.tsx index 393ce44b57..cb0eabd0ec 100644 --- a/packages/stack/src/components-page/oauth-callback.tsx +++ b/packages/stack/src/components-page/oauth-callback.tsx @@ -42,4 +42,4 @@ export function OAuthCallback(props: { fullPage?: boolean }) {

{t("This is most likely an error in Stack. Please report it.")}

: null} ; -} \ No newline at end of file +} diff --git a/packages/stack/src/components-page/password-reset.tsx b/packages/stack/src/components-page/password-reset.tsx index 24c1494c5b..6d28559f40 100644 --- a/packages/stack/src/components-page/password-reset.tsx +++ b/packages/stack/src/components-page/password-reset.tsx @@ -78,7 +78,7 @@ export default function PasswordResetForm(props: { return (
diff --git a/packages/stack/src/components-page/stack-handler.tsx b/packages/stack/src/components-page/stack-handler.tsx index 94317af74c..1803b10d8d 100644 --- a/packages/stack/src/components-page/stack-handler.tsx +++ b/packages/stack/src/components-page/stack-handler.tsx @@ -73,18 +73,12 @@ export default async function StackHandler(props: oauthCallback: 'oauth-callback', magicLinkCallback: 'magic-link-callback', teamInvitation: 'team-invitation', + accountSettings: 'account-settings', error: 'error', }; const path = props.params.stack.join('/'); - if (path.startsWith('account-settings')) { - return ; - } - switch (path) { case availablePaths.signIn: { redirectIfNotHandler('signIn'); @@ -155,6 +149,12 @@ export default async function StackHandler(props: {...filterUndefinedINU(props.componentProps?.TeamInvitation)} />; } + case availablePaths.accountSettings: { + return ; + } case availablePaths.error: { return -
+
{t('Create a Team')} diff --git a/packages/stack/src/components/credential-sign-up.tsx b/packages/stack/src/components/credential-sign-up.tsx index 542b2a367b..c8a5c649a3 100644 --- a/packages/stack/src/components/credential-sign-up.tsx +++ b/packages/stack/src/components/credential-sign-up.tsx @@ -61,11 +61,11 @@ export function CredentialSignUp(props: { noPasswordRepeat?: boolean }) { onSubmit={e => runAsynchronouslyWithAlert(handleSubmit(onSubmit)(e))} noValidate > - + - + - + - Sign Up + {t('Sign Up')} ); diff --git a/packages/stack/src/components/elements/sidebar-layout.tsx b/packages/stack/src/components/elements/sidebar-layout.tsx index bd87071eee..367e4e6305 100644 --- a/packages/stack/src/components/elements/sidebar-layout.tsx +++ b/packages/stack/src/components/elements/sidebar-layout.tsx @@ -1,41 +1,45 @@ 'use client'; +import { useHash } from '@stackframe/stack-shared/dist/hooks/use-hash'; import { Button, Typography, cn } from '@stackframe/stack-ui'; import { LucideIcon, XIcon } from 'lucide-react'; -import { usePathname, useRouter } from 'next/navigation'; -import React, { ReactNode } from 'react'; +import { useRouter } from 'next/navigation'; +import React, { ReactNode, useEffect } from 'react'; export type SidebarItem = { title: React.ReactNode, type: 'item' | 'divider', description?: React.ReactNode, - subpath?: string, + id?: string, icon?: LucideIcon, content?: React.ReactNode, contentTitle?: React.ReactNode, } -export function SidebarLayout(props: { items: SidebarItem[], title?: ReactNode, basePath: string, className?: string }) { - const pathname = usePathname(); - const selectedIndex = props.items.findIndex(item => item.subpath && (props.basePath + item.subpath === pathname)); +export function SidebarLayout(props: { items: SidebarItem[], title?: ReactNode, className?: string }) { const router = useRouter(); - if (pathname !== props.basePath && selectedIndex === -1) { - router.push(props.basePath); - } + const hash = useHash(); + const selectedIndex = props.items.findIndex(item => item.id && (item.id === hash)); + + useEffect(() => { + if (selectedIndex === -1) { + router.push('#' + props.items[0].id); + } + }, [hash]); return ( <>
- +
- +
); } -function Items(props: { items: SidebarItem[], basePath: string, selectedIndex: number }) { +function Items(props: { items: SidebarItem[], selectedIndex: number }) { const router = useRouter(); return props.items.map((item, index) => ( @@ -49,8 +53,8 @@ function Items(props: { items: SidebarItem[], basePath: string, selectedIndex: n "justify-start text-md text-zinc-800 dark:text-zinc-300 px-2 text-left", )} onClick={() => { - if (item.subpath) { - router.push(props.basePath + item.subpath); + if (item.id) { + router.push('#' + item.id); } }} > @@ -64,7 +68,7 @@ function Items(props: { items: SidebarItem[], basePath: string, selectedIndex: n } -function DesktopLayout(props: { items: SidebarItem[], title?: ReactNode, selectedIndex: number, basePath: string }) { +function DesktopLayout(props: { items: SidebarItem[], title?: ReactNode, selectedIndex: number }) { const selectedItem = props.items[props.selectedIndex === -1 ? 0 : props.selectedIndex]; return ( @@ -74,7 +78,7 @@ function DesktopLayout(props: { items: SidebarItem[], title?: ReactNode, selecte {props.title}
} - +
@@ -91,7 +95,7 @@ function DesktopLayout(props: { items: SidebarItem[], title?: ReactNode, selecte ); } -function MobileLayout(props: { items: SidebarItem[], title?: ReactNode, selectedIndex: number, basePath: string }) { +function MobileLayout(props: { items: SidebarItem[], title?: ReactNode, selectedIndex: number }) { const selectedItem = props.items[props.selectedIndex]; const router = useRouter(); @@ -102,7 +106,7 @@ function MobileLayout(props: { items: SidebarItem[], title?: ReactNode, selected {props.title}
} - +
); } else { @@ -114,7 +118,7 @@ function MobileLayout(props: { items: SidebarItem[], title?: ReactNode, selected @@ -127,4 +131,4 @@ function MobileLayout(props: { items: SidebarItem[], title?: ReactNode, selected
); } -} \ No newline at end of file +} diff --git a/packages/stack/src/components/elements/user-avatar.tsx b/packages/stack/src/components/elements/user-avatar.tsx index 3b0003d612..53cb849925 100644 --- a/packages/stack/src/components/elements/user-avatar.tsx +++ b/packages/stack/src/components/elements/user-avatar.tsx @@ -25,4 +25,4 @@ export function UserAvatar(props: { ); -} \ No newline at end of file +} diff --git a/packages/stack/src/components/message-cards/message-card.tsx b/packages/stack/src/components/message-cards/message-card.tsx index b2dc46f7c4..7f7c87146c 100644 --- a/packages/stack/src/components/message-cards/message-card.tsx +++ b/packages/stack/src/components/message-cards/message-card.tsx @@ -18,7 +18,7 @@ export function MessageCard( ) { return ( -
+
{props.title} {props.children} {(props.primaryButtonText || props.secondaryButtonText) && ( diff --git a/packages/stack/src/components/message-cards/predefined-message-card.tsx b/packages/stack/src/components/message-cards/predefined-message-card.tsx index a4c16ba2fd..4ad80b26bd 100644 --- a/packages/stack/src/components/message-cards/predefined-message-card.tsx +++ b/packages/stack/src/components/message-cards/predefined-message-card.tsx @@ -1,7 +1,7 @@ "use client"; import { Typography } from "@stackframe/stack-ui"; -import { useStackApp } from "../.."; +import { useStackApp, useUser } from "../.."; import { MessageCard } from "./message-card"; import { useTranslation } from "../../lib/translations"; @@ -13,6 +13,7 @@ export function PredefinedMessageCard({ fullPage?: boolean, }) { const stackApp = useStackApp(); + const user = useUser(); const { t } = useTranslation(); let title: string; @@ -62,8 +63,13 @@ export function PredefinedMessageCard({ case 'emailVerified': { title = t("Email verified!"); message = t("Your have successfully verified your email."); - primaryAction = () => stackApp.redirectToSignIn({ noRedirectBack: true }); - primaryButton = t("Sign in"); + if (user) { + primaryAction = () => stackApp.redirectToSignIn({ noRedirectBack: true }); + primaryButton = t("Sign in"); + } else { + primaryAction = () => stackApp.redirectToHome(); + primaryButton = t("Go to home"); + } break; } case 'unknownError': { diff --git a/packages/stack/src/components/oauth-button.tsx b/packages/stack/src/components/oauth-button.tsx index e1d3ea6374..c416d91005 100644 --- a/packages/stack/src/components/oauth-button.tsx +++ b/packages/stack/src/components/oauth-button.tsx @@ -302,7 +302,7 @@ export function OAuthButton({
); -} \ No newline at end of file +} diff --git a/packages/stack/src/components/selected-team-switcher.tsx b/packages/stack/src/components/selected-team-switcher.tsx index a2b2a5ed52..4164168259 100644 --- a/packages/stack/src/components/selected-team-switcher.tsx +++ b/packages/stack/src/components/selected-team-switcher.tsx @@ -60,15 +60,17 @@ export function SelectedTeamSwitcher(props: SelectedTeamSwitcherProps) { }); }} > - + {user?.selectedTeam ?
- {t('Current team')} -
@@ -76,7 +78,7 @@ export function SelectedTeamSwitcher(props: SelectedTeamSwitcherProps) {
- {user.selectedTeam.displayName} + {user.selectedTeam.displayName}
: undefined} @@ -89,7 +91,7 @@ export function SelectedTeamSwitcher(props: SelectedTeamSwitcherProps) {
- {team.displayName} + {team.displayName}
))} @@ -102,7 +104,7 @@ export function SelectedTeamSwitcher(props: SelectedTeamSwitcherProps) {