diff --git a/.github/assets/logo.png b/.github/assets/logo.png index 0ba1cf944..d7112e209 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 5e314fa76..40f018031 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 a72c46d9f..778421b90 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 e362b4d06..1507212c5 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 39212ece7..2cb4cd48b 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 484dde25a..8e0959e88 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 8894f4951..4ff4cd924 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 2d0817059..a28fac8ca 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 0c56ba150..b0e324b48 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 07c907b7e..7e70a5a44 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 a38d1c699..b2f09d8d9 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 8ba1e50cd..5fb7beada 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 000000000..02d6fb69b --- /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 60aac5646..1b888e32f 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 49a0a4f85..7dfc0fe57 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 cf91b8559..cd1e21ce0 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 83c6df4c1..c5ef25bfa 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 303bd8051..149bd2b9e 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 000000000..7ebf96950 --- /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 2341311bc..955afdcf0 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 e042a92c7..a6058afe6 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 a066488ee..765a62f27 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 3db28164d..5a362dbba 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 000000000..d3ba6e2a7 --- /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 b8bc85e2a..99cf78f58 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 000000000..285463a30 --- /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 000000000..a9256aa62 --- /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 000000000..89da7b6e8 --- /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 000000000..007bbf0ec --- /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 8064f1330..4e121920b 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 2813d86de..97e11c30a 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 7ca3d8e17..9d10f0e0c 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 178277b08..f6ca2739d 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 1149b99e5..b676b495f 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 2c6e3528f..1d3cb8c47 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 96a18c19b..2906aad51 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 9002052f1..6a5529f35 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 8370dd2b8..2ffad7ce3 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 9a4bb6a9d..72ea618ff 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 5f8d2a52f..5ccb65c32 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 edfcbf4d5..daa39db96 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 60b4dc829..9236f8585 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 0f9e27731..05b53fd69 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 a237b21e1..9434ce32f 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 c9a1d8943..071e31ef9 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 317460b53..086d36bfb 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 65bd71b44..8013ac1b6 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 90d7d58b3..4410e461b 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 d7f31e08b..357fd928b 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 03511e79e..4e94ede80 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 e7e03b077..61008d842 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 7f37d18c9..942b29afc 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 000000000..d3e9c4626 --- /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 cdbcd88eb..3dc5b870d 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 4301a17bc..d7cf8be1d 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 d40d3eacd..9de00fa7a 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 d05d6c452..bf161e89a 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 1aad8d59b..5a2065554 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 1b93e7d0e..80b2e89b8 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 858ae5357..8ed79e0fe 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 0359aa478..4ab24f6e7 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 b0ac14ed8..a10eee901 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 a0401e569..8f4aa14f7 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 73ab92e96..a90ecd3a6 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 0fc70c1d6..58b0e9c06 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 c5246540c..a92c89f10 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 d8c0dcaa5..c20b9f160 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 df7a5113d..8a1b54da6 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 88e4e5512..ad33e5605 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 fd135f17b..3a0f80d4d 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 6395eb6a9..7ea9011e2 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 2f9852a5f..cad6a3a65 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 f1ad358ef..f38cfa9f0 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 c27ffb2be..a2530db07 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 4c2b10aeb..1d3f0bdd3 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 88562649b..8034e646b 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 42081170e..c431ca818 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 5ae44476f..64f69adf8 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 e26bedabc..86b9b91dc 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 24bb666ae..ad78956d1 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 d1fab6d0e..6de602446 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 1369bf4bf..40ef43154 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 7e4f73b3e..238829c62 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 d0ed5eacf..85b76050f 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 3db5dec02..7f3db878a 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 5b2b2dd2e..902cd4425 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 34b8b8cc3..6eeddeb86 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 c09c3f1dd..107b22c41 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 373983e34..365058ceb 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 39645b397..d6b0f86b4 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 25cee1c52..16519e912 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 f83322b49..bb7daafed 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 39831fa70..71716e80d 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 abec29aec..5fdd1a3a6 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 a47645a5b..214f809ee 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 4cf1ac976..82561a883 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 393ce44b5..cb0eabd0e 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 24c1494c5..6d28559f4 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 94317af74..1803b10d8 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 542b2a367..c8a5c649a 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 bd87071ee..367e4e630 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 3b0003d61..53cb84992 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 b2dc46f7c..7f7c87146 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 a4c16ba2f..4ad80b26b 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 e1d3ea637..c416d9100 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 a2b2a5ed5..416416825 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) {