diff --git a/apps/backend/src/app/api/latest/contact-channels/crud.tsx b/apps/backend/src/app/api/latest/contact-channels/crud.tsx index 9222dbbd9..072f30e67 100644 --- a/apps/backend/src/app/api/latest/contact-channels/crud.tsx +++ b/apps/backend/src/app/api/latest/contact-channels/crud.tsx @@ -1,3 +1,4 @@ +import { normalizeEmail } from "@/lib/emails"; import { ensureContactChannelDoesNotExists, ensureContactChannelExists } from "@/lib/request-checks"; import { prismaClient, retryTransaction } from "@/prisma-client"; import { createCrudHandlers } from "@/route-handlers/crud-handler"; @@ -55,6 +56,14 @@ export const contactChannelsCrudHandlers = createLazyProxy(() => createCrudHandl return contactChannelToCrud(contactChannel); }, onCreate: async ({ auth, data }) => { + let value = data.value; + switch (data.type) { + case 'email': { + value = normalizeEmail(value); + break; + } + } + if (auth.type === 'client') { const currentUserId = auth.user?.id || throwErr(new KnownErrors.CannotGetOwnUserWithoutUser()); if (currentUserId !== data.user_id) { @@ -67,7 +76,7 @@ export const contactChannelsCrudHandlers = createLazyProxy(() => createCrudHandl tenancyId: auth.tenancy.id, userId: data.user_id, type: data.type, - value: data.value, + value: value, }); // if usedForAuth is set to true, make sure no other account uses this channel for auth @@ -77,13 +86,13 @@ export const contactChannelsCrudHandlers = createLazyProxy(() => createCrudHandl tenancyId_type_value_usedForAuth: { tenancyId: auth.tenancy.id, type: crudContactChannelTypeToPrisma(data.type), - value: data.value, + value: value, usedForAuth: 'TRUE', }, }, }); if (existingWithSameChannel) { - throw new KnownErrors.ContactChannelAlreadyUsedForAuthBySomeoneElse(data.type, data.value); + throw new KnownErrors.ContactChannelAlreadyUsedForAuthBySomeoneElse(data.type, value); } } @@ -92,7 +101,7 @@ export const contactChannelsCrudHandlers = createLazyProxy(() => createCrudHandl tenancyId: auth.tenancy.id, projectUserId: data.user_id, type: typedToUppercase(data.type), - value: data.value, + value: value, isVerified: data.is_verified ?? false, usedForAuth: data.used_for_auth ? 'TRUE' : null, }, @@ -145,6 +154,17 @@ export const contactChannelsCrudHandlers = createLazyProxy(() => createCrudHandl } } + let value = data.value; + switch (data.type) { + case 'email': { + value = value ? normalizeEmail(value) : undefined; + break; + } + case undefined: { + break; + } + } + const updatedContactChannel = await retryTransaction(async (tx) => { const existingContactChannel = await ensureContactChannelExists(tx, { tenancyId: auth.tenancy.id, @@ -159,7 +179,7 @@ export const contactChannelsCrudHandlers = createLazyProxy(() => createCrudHandl tenancyId_type_value_usedForAuth: { tenancyId: auth.tenancy.id, type: data.type !== undefined ? crudContactChannelTypeToPrisma(data.type) : existingContactChannel.type, - value: data.value !== undefined ? data.value : existingContactChannel.value, + value: value !== undefined ? value : existingContactChannel.value, usedForAuth: 'TRUE', }, }, @@ -191,8 +211,8 @@ export const contactChannelsCrudHandlers = createLazyProxy(() => createCrudHandl }, }, 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 + value: value, + isVerified: data.is_verified ?? (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, }, diff --git a/apps/backend/src/app/api/latest/internal/failed-emails-digest/crud.tsx b/apps/backend/src/app/api/latest/internal/failed-emails-digest/crud.tsx index 8c41b8f2c..1b3936f02 100644 --- a/apps/backend/src/app/api/latest/internal/failed-emails-digest/crud.tsx +++ b/apps/backend/src/app/api/latest/internal/failed-emails-digest/crud.tsx @@ -10,7 +10,7 @@ type FailedEmailsQueryResult = { type FailedEmailsByTenancyData = { emails: Array<{ subject: string, to: string[] }>, - tenantOwnerEmail: string, + tenantOwnerEmails: string[], projectId: string, } @@ -24,10 +24,11 @@ export const getFailedEmailsByTenancy = async (after: Date) => { cc."value" as "contactEmail" FROM "SentEmail" se INNER JOIN "Tenancy" t ON se."tenancyId" = t.id + INNER JOIN "Project" p ON t."projectId" = p.id LEFT JOIN "ProjectUser" pu ON pu."mirroredProjectId" = 'internal' AND pu."mirroredBranchId" = 'main' AND pu."serverMetadata"->'managedProjectIds' ? t."projectId" - LEFT JOIN "ContactChannel" cc ON pu."projectUserId" = cc."projectUserId" + INNER JOIN "ContactChannel" cc ON pu."projectUserId" = cc."projectUserId" AND cc."isPrimary" = 'TRUE' AND cc."type" = 'EMAIL' WHERE se."error" IS NOT NULL @@ -38,10 +39,11 @@ export const getFailedEmailsByTenancy = async (after: Date) => { for (const failedEmail of result) { let failedEmails = failedEmailsByTenancy.get(failedEmail.tenancyId) ?? { emails: [], - tenantOwnerEmail: failedEmail.contactEmail, + tenantOwnerEmails: [], projectId: failedEmail.projectId }; failedEmails.emails.push({ subject: failedEmail.subject, to: failedEmail.to }); + failedEmails.tenantOwnerEmails.push(failedEmail.contactEmail); failedEmailsByTenancy.set(failedEmail.tenancyId, failedEmails); } return failedEmailsByTenancy; diff --git a/apps/backend/src/app/api/latest/internal/failed-emails-digest/route.ts b/apps/backend/src/app/api/latest/internal/failed-emails-digest/route.ts index 247a4d962..b460ede03 100644 --- a/apps/backend/src/app/api/latest/internal/failed-emails-digest/route.ts +++ b/apps/backend/src/app/api/latest/internal/failed-emails-digest/route.ts @@ -31,7 +31,7 @@ export const POST = createSmartRouteHandler({ subject: yupString().defined(), to: yupArray(yupString().defined()).defined(), })).defined(), - tenant_owner_email: yupString().defined(), + tenant_owner_emails: yupArray(yupString().defined()).defined(), project_id: yupString().defined(), tenancy_id: yupString().defined(), })).optional(), @@ -50,6 +50,10 @@ export const POST = createSmartRouteHandler({ let anyDigestsFailedToSend = false; for (const failedEmailsBatch of failedEmailsByTenancy.values()) { + if (!failedEmailsBatch.tenantOwnerEmails.length) { + continue; + } + const viewInStackAuth = `View all email logs on the Dashboard`; const emailHtml = `

Thank you for using Stack Auth!

@@ -57,10 +61,10 @@ export const POST = createSmartRouteHandler({

${viewInStackAuth}

Last failing emails:

${failedEmailsBatch.emails.slice(-10).map((failedEmail) => { - const escapedSubject = escapeHtml(failedEmail.subject).replace(/\s+/g, ' ').slice(0, 50); - const escapedTo = failedEmail.to.map(to => escapeHtml(to)).join(", "); - return `

Subject: ${escapedSubject}
To: ${escapedTo}

`; - }).join("")} + const escapedSubject = escapeHtml(failedEmail.subject).replace(/\s+/g, ' ').slice(0, 50); + const escapedTo = failedEmail.to.map(to => escapeHtml(to)).join(", "); + return `

Subject: ${escapedSubject}
To: ${escapedTo}

`; + }).join("")} ${failedEmailsBatch.emails.length > 10 ? `
...
` : ""} `; if (query.dry_run !== "true") { @@ -68,7 +72,7 @@ export const POST = createSmartRouteHandler({ await sendEmail({ tenancyId: internalTenancy.id, emailConfig, - to: failedEmailsBatch.tenantOwnerEmail, + to: failedEmailsBatch.tenantOwnerEmails, subject: "Failed emails digest", html: emailHtml, }); @@ -87,7 +91,7 @@ export const POST = createSmartRouteHandler({ failed_emails_by_tenancy: Array.from(failedEmailsByTenancy.entries()).map(([tenancyId, batch]) => ( { emails: batch.emails, - tenant_owner_email: batch.tenantOwnerEmail, + tenant_owner_emails: batch.tenantOwnerEmails, project_id: batch.projectId, tenancy_id: tenancyId, } diff --git a/apps/backend/src/app/api/latest/users/crud.tsx b/apps/backend/src/app/api/latest/users/crud.tsx index c9df4a88e..52a07d766 100644 --- a/apps/backend/src/app/api/latest/users/crud.tsx +++ b/apps/backend/src/app/api/latest/users/crud.tsx @@ -1,3 +1,4 @@ +import { normalizeEmail } from "@/lib/emails"; import { grantDefaultProjectPermissions } from "@/lib/permissions"; import { ensureTeamMembershipExists, ensureUserExists } from "@/lib/request-checks"; import { getTenancy } from "@/lib/tenancies"; @@ -477,9 +478,11 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC }; }, onCreate: async ({ auth, data }) => { + const primaryEmail = data.primary_email ? normalizeEmail(data.primary_email) : data.primary_email; + log("create_user_endpoint_primaryAuthEnabled", { value: data.primary_email_auth_enabled, - email: data.primary_email ?? undefined, + email: primaryEmail ?? undefined, projectId: auth.project.id, }); @@ -487,7 +490,7 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC const result = await retryTransaction(async (tx) => { await checkAuthData(tx, { tenancyId: auth.tenancy.id, - primaryEmail: data.primary_email, + primaryEmail: primaryEmail, primaryEmailVerified: !!data.primary_email_verified, primaryEmailAuthEnabled: !!data.primary_email_auth_enabled, }); @@ -552,13 +555,13 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC } - if (data.primary_email) { + if (primaryEmail) { await tx.contactChannel.create({ data: { projectUserId: newUser.projectUserId, tenancyId: auth.tenancy.id, type: 'EMAIL' as const, - value: data.primary_email, + value: primaryEmail, isVerified: data.primary_email_verified ?? false, isPrimary: "TRUE", usedForAuth: data.primary_email_auth_enabled ? BooleanTrue.TRUE : null, @@ -629,8 +632,8 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC data: { display_name: data.display_name ? `${data.display_name}'s Team` : - data.primary_email ? - `${data.primary_email}'s Team` : + primaryEmail ? + `${primaryEmail}'s Team` : "Personal Team", creator_user_id: 'me', }, @@ -660,6 +663,7 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC return result; }, onUpdate: async ({ auth, data, params }) => { + const primaryEmail = data.primary_email ? normalizeEmail(data.primary_email) : data.primary_email; const passwordHash = await getPasswordHashFromData(data); const result = await retryTransaction(async (tx) => { await ensureUserExists(tx, { tenancyId: auth.tenancy.id, userId: params.user_id }); @@ -743,7 +747,7 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC await checkAuthData(tx, { tenancyId: auth.tenancy.id, oldPrimaryEmail: primaryEmailContactChannel?.value, - primaryEmail: data.primary_email || primaryEmailContactChannel?.value, + primaryEmail: primaryEmail || primaryEmailContactChannel?.value, primaryEmailVerified, primaryEmailAuthEnabled, }); @@ -753,8 +757,8 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC // - update the primary email contact channel if it exists // if the primary email is null // - delete the primary email contact channel if it exists (note that this will also delete the related auth methods) - if (data.primary_email !== undefined) { - if (data.primary_email === null) { + if (primaryEmail !== undefined) { + if (primaryEmail === null) { await tx.contactChannel.delete({ where: { tenancyId_projectUserId_type_isPrimary: { @@ -779,13 +783,13 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC projectUserId: params.user_id, tenancyId: auth.tenancy.id, type: 'EMAIL' as const, - value: data.primary_email, + value: primaryEmail, isVerified: false, isPrimary: "TRUE", usedForAuth: primaryEmailAuthEnabled ? BooleanTrue.TRUE : null, }, update: { - value: data.primary_email, + value: primaryEmail, usedForAuth: primaryEmailAuthEnabled ? BooleanTrue.TRUE : null, } }); @@ -812,7 +816,7 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC // if primary_email_auth_enabled is being updated without changing the email // - update the primary email contact channel's usedForAuth field - if (data.primary_email_auth_enabled !== undefined && data.primary_email === undefined) { + if (data.primary_email_auth_enabled !== undefined && primaryEmail === undefined) { await tx.contactChannel.update({ where: { tenancyId_projectUserId_type_isPrimary: { diff --git a/apps/backend/src/lib/emails.tsx b/apps/backend/src/lib/emails.tsx index 09ebe6491..7c54691eb 100644 --- a/apps/backend/src/lib/emails.tsx +++ b/apps/backend/src/lib/emails.tsx @@ -367,3 +367,39 @@ export async function getSharedEmailConfig(displayName: string): Promise { + expect(normalizeEmail('Example.Test@gmail.com')).toBe('exampletest@gmail.com'); + expect(normalizeEmail('Example.Test+123@gmail.com')).toBe('exampletest+123@gmail.com'); + expect(normalizeEmail('exampletest@gmail.com')).toBe('exampletest@gmail.com'); + expect(normalizeEmail('EXAMPLETEST@gmail.com')).toBe('exampletest@gmail.com'); + + expect(normalizeEmail('user@example.com')).toBe('user@example.com'); + expect(normalizeEmail('user.name+tag@example.com')).toBe('user.name+tag@example.com'); + + expect(() => normalizeEmail('test@multiple@domains.com')).toThrow(); + expect(() => normalizeEmail('invalid.email')).toThrow(); +}); diff --git a/apps/backend/src/oauth/providers/google.tsx b/apps/backend/src/oauth/providers/google.tsx index bee23ae11..6ff97ad98 100644 --- a/apps/backend/src/oauth/providers/google.tsx +++ b/apps/backend/src/oauth/providers/google.tsx @@ -22,6 +22,10 @@ export class GoogleProvider extends OAuthBaseProvider { openid: true, jwksUri: "https://www.googleapis.com/oauth2/v3/certs", baseScope: "https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile", + authorizationExtraParams: { + prompt: "consent", + include_granted_scopes: "true", + }, ...options, })); } diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/page-client.tsx index fcaa9b3c6..dfc0b5a36 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/page-client.tsx @@ -330,6 +330,13 @@ function UserHeader({ user }: UserHeaderProps) { }}> Impersonate + {user.isMultiFactorRequired && ( + { + await user.update({ totpMultiFactorSecret: null }); + }}> + Remove 2FA + + )} setIsDeleteModalOpen(true)}> Delete @@ -373,7 +380,7 @@ function UserDetails({ user }: UserDetailsProps) { /> } name="2-factor auth"> - + } name="Signed up at"> diff --git a/apps/dashboard/src/app/background-shine.tsx b/apps/dashboard/src/app/background-shine.tsx index 1e9a3e13f..67d77ff8b 100644 --- a/apps/dashboard/src/app/background-shine.tsx +++ b/apps/dashboard/src/app/background-shine.tsx @@ -18,6 +18,7 @@ export function BackgroundShine() { transition: 'transform 0.05s ease-in-out', transform: `translateY(${-scrollY * 0.2}px)`, }} + inert={true} >
) { - backendContext.set({ - projectKeys: InternalProjectKeys, - userAuth: null, - }); + export async function createAndGetAdminToken(body?: Partial, useExistingUser?: boolean) { + backendContext.set({ projectKeys: InternalProjectKeys }); const oldMailbox = backendContext.value.mailbox; - await bumpEmailAddress({ unindexed: true }); - const { userId } = await Auth.Otp.signIn(); + let userId: string | undefined; + if (!useExistingUser) { + backendContext.set({ userAuth: null }); + await bumpEmailAddress({ unindexed: true }); + const { userId: newUserId } = await Auth.Otp.signIn(); + userId = newUserId; + } const adminAccessToken = backendContext.value.userAuth?.accessToken; expect(adminAccessToken).toBeDefined(); const { projectId, createProjectResponse } = await Project.create(body); @@ -1083,13 +1085,14 @@ export namespace Project { }; } - export async function createAndSwitch(body?: Partial) { - const createResult = await Project.createAndGetAdminToken(body); + export async function createAndSwitch(body?: Partial, useExistingUser?: boolean) { + const createResult = await Project.createAndGetAdminToken(body, useExistingUser); backendContext.set({ projectKeys: { projectId: createResult.projectId, adminAccessToken: createResult.adminAccessToken, }, + userAuth: null }); return createResult; } diff --git a/apps/e2e/tests/backend/endpoints/api/v1/internal/failed-emails-digest.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/internal/failed-emails-digest.test.ts index b0eeef7d2..eddb332b2 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/internal/failed-emails-digest.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/internal/failed-emails-digest.test.ts @@ -1,6 +1,6 @@ import { describe } from "vitest"; import { it } from "../../../../../helpers"; -import { Auth, backendContext, InternalProjectKeys, niceBackendFetch, Project } from "../../../../backend-helpers"; +import { Auth, backendContext, bumpEmailAddress, InternalProjectKeys, niceBackendFetch, Project, User } from "../../../../backend-helpers"; describe("unauthorized requests", () => { it("should return 401 when invalid authorization is provided", async ({ expect }) => { @@ -62,35 +62,13 @@ describe("with valid credentials", () => { userAuth: null, }); await Auth.Otp.signIn(); - const adminAccessToken = backendContext.value.userAuth?.accessToken; - const { projectId } = await Project.create({ + await Project.createAndSwitch({ display_name: "Test Failed Emails Project", - config: { - email_config: { - type: "standard", - host: "invalid-smtp-host.example.com", - port: 587, - username: "invalid_user", - password: "invalid_password", - sender_name: "Test Project", - sender_email: "test@invalid-domain.example.com", - }, - }, - }); - - backendContext.set({ - projectKeys: { - projectId, - }, - userAuth: null, - }); + }, true); const testEmailResponse = await niceBackendFetch("/api/v1/internal/send-test-email", { method: "POST", accessType: "admin", - headers: { - "x-stack-admin-access-token": adminAccessToken, - }, body: { "recipient_email": "test-email-recipient@stackframe.co", "email_config": { @@ -119,11 +97,10 @@ describe("with valid credentials", () => { headers: { "Authorization": "Bearer mock_cron_secret" } }); expect(response.status).toBe(200); - console.log(response.body); const failedEmailsByTenancy = response.body.failed_emails_by_tenancy; const mockProjectFailedEmails = failedEmailsByTenancy.filter( - (batch: any) => batch.tenant_owner_email === backendContext.value.mailbox.emailAddress + (batch: any) => batch.tenant_owner_emails.includes(backendContext.value.mailbox.emailAddress) ); expect(mockProjectFailedEmails).toMatchInlineSnapshot(` [ @@ -136,7 +113,7 @@ describe("with valid credentials", () => { ], "project_id": "", "tenancy_id": "", - "tenant_owner_email": "default-mailbox--@stack-generated.example.com", + "tenant_owner_emails": ["default-mailbox--@stack-generated.example.com"], }, ] `); @@ -149,19 +126,8 @@ describe("with valid credentials", () => { it("should return 200 and not send digest email when all emails are successful", async ({ expect }) => { await Auth.Otp.signIn(); - const { projectId } = await Project.create({ + await Project.create({ display_name: "Test Successful Emails Project", - config: { - email_config: { - type: "standard", - host: "localhost", - port: 2500, - username: "test", - password: "test", - sender_name: "Test Project", - sender_email: "test@example.com", - }, - }, }); const response = await niceBackendFetch("/api/v1/internal/failed-emails-digest", { @@ -180,4 +146,211 @@ describe("with valid credentials", () => { const digestEmail = messages.find(msg => msg.subject === "Failed emails digest"); expect(digestEmail).toBeUndefined(); }); + + it("should not send digest email when project owner has no primary email", async ({ expect }) => { + backendContext.set({ + projectKeys: InternalProjectKeys, + userAuth: null, + }); + const { userId } = await Auth.Otp.signIn(); + + // Remove primary email from the user + const updateEmailResponse = await niceBackendFetch(`/api/v1/users/${userId}`, { + method: "PATCH", + accessType: "admin", + body: { + "primary_email": null, + }, + }); + expect(updateEmailResponse.status).toBe(200); + + await Project.createAndSwitch({ + display_name: "Test Project No Owner Email", + }); + + // Send a test email that will fail + await niceBackendFetch("/api/v1/internal/send-test-email", { + method: "POST", + accessType: "admin", + body: { + "recipient_email": "test-email-recipient@stackframe.co", + "email_config": { + "host": "this-is-not-a-valid-host.example.com", + "port": 123, + "username": "123", + "password": "123", + "sender_email": "123@g.co", + "sender_name": "123" + } + }, + }); + + const response = await niceBackendFetch("/api/v1/internal/failed-emails-digest", { + method: "POST", + headers: { "Authorization": "Bearer mock_cron_secret" } + }); + expect(response.status).toBe(200); + + const messages = await backendContext.value.mailbox.fetchMessages(); + const digestEmail = messages.find(msg => msg.subject === "Failed emails digest"); + expect(digestEmail).toBeUndefined(); + }); + + it("should not send digest email when project has no owner (account deleted)", async ({ expect }) => { + const { userId } = await Auth.Otp.signIn(); + await Project.createAndSwitch({ + display_name: "Test Project Deleted Owner", + }, true); + + // Send a test email that will fail + await niceBackendFetch("/api/v1/internal/send-test-email", { + method: "POST", + accessType: "admin", + body: { + "recipient_email": "test-email-recipient@stackframe.co", + "email_config": { + "host": "this-is-not-a-valid-host.example.com", + "port": 123, + "username": "123", + "password": "123", + "sender_email": "123@g.co", + "sender_name": "123" + } + }, + }); + + // Delete the user account (project owner) + backendContext.set({ + projectKeys: InternalProjectKeys, + }); + const deleteUserResponse = await niceBackendFetch(`/api/v1/users/${userId}`, { + method: "DELETE", + accessType: "admin", + }); + expect(deleteUserResponse.body).toMatchInlineSnapshot(`{ "success": true }`); + + const response = await niceBackendFetch("/api/v1/internal/failed-emails-digest", { + method: "POST", + headers: { "Authorization": "Bearer mock_cron_secret" } + }); + expect(response.status).toBe(200); + + // Should not send digest email when project owner is deleted + const messages = await backendContext.value.mailbox.fetchMessages(); + const digestEmail = messages.find(msg => msg.subject === "Failed emails digest"); + expect(digestEmail).toBeUndefined(); + }); + + it("should not send digest email when project is deleted after email delivery failed", async ({ expect }) => { + await Auth.Otp.signIn(); + await Project.createAndSwitch({ + display_name: "Test Project To Be Deleted", + }, true); + + // Send a test email that will fail + await niceBackendFetch("/api/v1/internal/send-test-email", { + method: "POST", + accessType: "admin", + body: { + "recipient_email": "test-email-recipient@stackframe.co", + "email_config": { + "host": "this-is-not-a-valid-host.example.com", + "port": 123, + "username": "123", + "password": "123", + "sender_email": "123@g.co", + "sender_name": "123" + } + }, + }); + + // Delete the project + const deleteProjectResponse = await niceBackendFetch(`/api/v1/internal/projects/current`, { + method: "DELETE", + accessType: "admin", + }); + expect(deleteProjectResponse.body).toMatchInlineSnapshot(`{ "success": true }`); + + const response = await niceBackendFetch("/api/v1/internal/failed-emails-digest", { + method: "POST", + headers: { "Authorization": "Bearer mock_cron_secret" } + }); + expect(response.status).toBe(200); + + // Should not send digest email when project is deleted + const messages = await backendContext.value.mailbox.fetchMessages(); + const digestEmail = messages.find(msg => msg.subject === "Failed emails digest"); + expect(digestEmail).toBeUndefined(); + }); + + it("should send digest email to each owner when project has multiple owners", async ({ expect }) => { + const firstOwnerMailbox = backendContext.value.mailbox; + backendContext.set({ + projectKeys: InternalProjectKeys, + }); + await Auth.Otp.signIn(); + const { projectId } = await Project.createAndSwitch({ + display_name: "Test Project Multiple Owners", + }, true); + const oldProjectKeys = backendContext.value.projectKeys; + const oldAuth = backendContext.value.userAuth; + const secondOwnerMailbox = await bumpEmailAddress(); + backendContext.set({ + projectKeys: InternalProjectKeys, + }); + const { userId } = await Auth.Otp.signIn(); + + const updateUserResponse = await niceBackendFetch(`/api/v1/users/${userId}`, { + method: "PATCH", + accessType: "admin", + body: { + server_metadata: { managedProjectIds: [projectId] } + }, + }); + expect(updateUserResponse.status).toBe(200); + backendContext.set({ projectKeys: oldProjectKeys, userAuth: oldAuth }); + + // Send a test email that will fail + const sendTestEmailResponse = await niceBackendFetch("/api/v1/internal/send-test-email", { + method: "POST", + accessType: "admin", + body: { + "recipient_email": "test-email-recipient@stackframe.co", + "email_config": { + "host": "this-is-not-a-valid-host.example.com", + "port": 123, + "username": "123", + "password": "123", + "sender_email": "123@g.co", + "sender_name": "123" + } + }, + }); + expect(sendTestEmailResponse.body).toMatchInlineSnapshot(` + { + "error_message": "Failed to connect to the email host. Please make sure the email host configuration is correct.", + "success": false, + } + `); + + const response = await niceBackendFetch("/api/v1/internal/failed-emails-digest", { + method: "POST", + headers: { "Authorization": "Bearer mock_cron_secret" } + }); + expect(response.status).toBe(200); + const currentResponses = response.body.failed_emails_by_tenancy.filter( + (batch: any) => batch.project_id === projectId + ); + expect(currentResponses.length).toBe(1); + expect(currentResponses[0].tenant_owner_emails.length).toBe(2); + expect(currentResponses[0].tenant_owner_emails.includes(firstOwnerMailbox.emailAddress)).toBe(true); + expect(currentResponses[0].tenant_owner_emails.includes(secondOwnerMailbox.emailAddress)).toBe(true); + + const firstMailboxMessages = await firstOwnerMailbox.fetchMessages(); + const secondMailboxMessages = await secondOwnerMailbox.fetchMessages(); + const firstMailboxDigestEmail = firstMailboxMessages.find(msg => msg.subject === "Failed emails digest"); + const secondMailboxDigestEmail = secondMailboxMessages.find(msg => msg.subject === "Failed emails digest"); + expect(firstMailboxDigestEmail).toBeDefined(); + expect(secondMailboxDigestEmail).toBeDefined(); + }); }); diff --git a/examples/docs-examples/src/app/team/[teamId]/page.tsx b/examples/docs-examples/src/app/team/[teamId]/page.tsx index 143f0ab1d..40cbb1df5 100644 --- a/examples/docs-examples/src/app/team/[teamId]/page.tsx +++ b/examples/docs-examples/src/app/team/[teamId]/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useUser, SelectedTeamSwitcher } from "@stackframe/stack"; +import { SelectedTeamSwitcher, useUser } from "@stackframe/stack"; export default function TeamPage({ params }: { params: { teamId: string } }) { const user = useUser({ or: 'redirect' }); diff --git a/packages/template/src/components/selected-team-switcher.tsx b/packages/template/src/components/selected-team-switcher.tsx index 6562371d2..18168f79a 100644 --- a/packages/template/src/components/selected-team-switcher.tsx +++ b/packages/template/src/components/selected-team-switcher.tsx @@ -1,4 +1,5 @@ 'use client'; +import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; import { Button, @@ -25,10 +26,13 @@ type MockTeam = { profileImageUrl?: string | null, }; -type SelectedTeamSwitcherProps = { - urlMap?: (team: Team) => string, +type SelectedTeamSwitcherProps = { + urlMap?: (team: AllowNull extends true ? Team | null : Team) => string, selectedTeam?: Team, noUpdateSelectedTeam?: boolean, + allowNull?: AllowNull, + nullLabel?: string, + onChange?: (team: AllowNull extends true ? Team | null : Team) => void, // Mock data props mockUser?: { selectedTeam?: MockTeam, @@ -41,7 +45,7 @@ type SelectedTeamSwitcherProps = { }, }; -export function SelectedTeamSwitcher(props: SelectedTeamSwitcherProps) { +export function SelectedTeamSwitcher(props: SelectedTeamSwitcherProps) { return }> ; @@ -51,7 +55,7 @@ function Fallback() { return ; } -function Inner(props: SelectedTeamSwitcherProps) { +function Inner(props: SelectedTeamSwitcherProps) { const { t } = useTranslation(); const appFromHook = useStackApp(); const userFromHook = useUser(); @@ -83,17 +87,27 @@ function Inner(props: SelectedTeamSwitcherProps) { return (