Merge dev into added_docs

This commit is contained in:
Konsti Wohlwend 2025-07-03 04:32:51 -07:00 committed by GitHub
commit b7eff595d7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 3610 additions and 3281 deletions

View File

@ -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,
},

View File

@ -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;

View File

@ -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 = `<a href="${dashboardUrl}/projects/${encodeURIComponent(failedEmailsBatch.projectId)}/emails">View all email logs on the Dashboard</a>`;
const emailHtml = `
<p>Thank you for using Stack Auth!</p>
@ -57,10 +61,10 @@ export const POST = createSmartRouteHandler({
<p>${viewInStackAuth}</p>
<p>Last failing emails:</p>
${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 `<div><p>Subject: ${escapedSubject}<br />To: ${escapedTo}</p></div>`;
}).join("")}
const escapedSubject = escapeHtml(failedEmail.subject).replace(/\s+/g, ' ').slice(0, 50);
const escapedTo = failedEmail.to.map(to => escapeHtml(to)).join(", ");
return `<div><p>Subject: ${escapedSubject}<br />To: ${escapedTo}</p></div>`;
}).join("")}
${failedEmailsBatch.emails.length > 10 ? `<div>...</div>` : ""}
`;
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,
}

View File

@ -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: {

View File

@ -367,3 +367,39 @@ export async function getSharedEmailConfig(displayName: string): Promise<EmailCo
type: 'shared',
};
}
export function normalizeEmail(email: string): string {
if (typeof email !== 'string') {
throw new TypeError('normalize-email expects a string');
}
const removeDotsDomains = ['gmail.com', 'googlemail.com', 'live.com'];
const emailLower = email.trim().toLowerCase();
const emailParts = emailLower.split(/@/);
if (emailParts.length !== 2) {
throw new StackAssertionError('Invalid email address', { email });
}
let [username, domain] = emailParts;
if (removeDotsDomains.includes(domain)) {
username = username.replace(/\.+/g, '');
}
return `${username}@${domain}`;
}
import.meta.vitest?.test('normalizeEmail(...)', async ({ expect }) => {
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();
});

View File

@ -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,
}));
}

View File

@ -330,6 +330,13 @@ function UserHeader({ user }: UserHeaderProps) {
}}>
<span>Impersonate</span>
</DropdownMenuItem>
{user.isMultiFactorRequired && (
<DropdownMenuItem onClick={async () => {
await user.update({ totpMultiFactorSecret: null });
}}>
<span>Remove 2FA</span>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setIsDeleteModalOpen(true)}>
<Typography className="text-destructive">Delete</Typography>
@ -373,7 +380,7 @@ function UserDetails({ user }: UserDetailsProps) {
/>
</UserInfo>
<UserInfo icon={<Shield size={16}/>} name="2-factor auth">
<EditableInput value={user.otpAuthEnabled ? 'Enabled' : ''} placeholder='Disabled' readOnly />
<EditableInput value={user.isMultiFactorRequired ? 'Enabled' : ''} placeholder='Disabled' readOnly />
</UserInfo>
<UserInfo icon={<Calendar size={16}/>} name="Signed up at">
<EditableInput value={user.signedUpAt.toDateString()} readOnly />

View File

@ -18,6 +18,7 @@ export function BackgroundShine() {
transition: 'transform 0.05s ease-in-out',
transform: `translateY(${-scrollY * 0.2}px)`,
}}
inert={true}
>
<div style={{
position: 'absolute',

View File

@ -1055,14 +1055,16 @@ export namespace Project {
};
}
export async function createAndGetAdminToken(body?: Partial<AdminUserProjectsCrud["Admin"]["Create"]>) {
backendContext.set({
projectKeys: InternalProjectKeys,
userAuth: null,
});
export async function createAndGetAdminToken(body?: Partial<AdminUserProjectsCrud["Admin"]["Create"]>, 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<AdminUserProjectsCrud["Admin"]["Create"]>) {
const createResult = await Project.createAndGetAdminToken(body);
export async function createAndSwitch(body?: Partial<AdminUserProjectsCrud["Admin"]["Create"]>, useExistingUser?: boolean) {
const createResult = await Project.createAndGetAdminToken(body, useExistingUser);
backendContext.set({
projectKeys: {
projectId: createResult.projectId,
adminAccessToken: createResult.adminAccessToken,
},
userAuth: null
});
return createResult;
}

View File

@ -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": "<stripped UUID>",
"tenancy_id": "<stripped UUID>",
"tenant_owner_email": "default-mailbox--<stripped UUID>@stack-generated.example.com",
"tenant_owner_emails": ["default-mailbox--<stripped UUID>@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();
});
});

View File

@ -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' });

View File

@ -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<AllowNull extends boolean = false> = {
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<AllowNull extends boolean = false>(props: SelectedTeamSwitcherProps<AllowNull>) {
return <Suspense fallback={<Fallback />}>
<Inner {...props} />
</Suspense>;
@ -51,7 +55,7 @@ function Fallback() {
return <Skeleton className="h-9 w-full max-w-64 stack-scope" />;
}
function Inner(props: SelectedTeamSwitcherProps) {
function Inner<AllowNull extends boolean>(props: SelectedTeamSwitcherProps<AllowNull>) {
const { t } = useTranslation();
const appFromHook = useStackApp();
const userFromHook = useUser();
@ -83,17 +87,27 @@ function Inner(props: SelectedTeamSwitcherProps) {
return (
<Select
value={selectedTeam?.id}
value={selectedTeam?.id || (props.allowNull ? 'null-sentinel' : undefined)}
onValueChange={(value) => {
// Skip actual navigation/updates in mock mode
if (props.mockUser) return;
runAsynchronouslyWithAlert(async () => {
const team = teams?.find(team => team.id === value);
if (!team) {
throw new Error('Team not found, this should not happen');
let team: MockTeam | null = null;
if (value !== 'null-sentinel') {
team = teams?.find(team => team.id === value) || null;
if (!team) {
throw new StackAssertionError('Team not found, this should not happen');
}
} else {
team = null;
}
// Call onChange callback if provided
if (props.onChange) {
props.onChange(team as Team);
}
// Skip actual navigation/updates in mock mode
if (props.mockUser) return;
if (!props.noUpdateSelectedTeam) {
await user?.setSelectedTeam(team as Team);
}
@ -136,6 +150,15 @@ function Inner(props: SelectedTeamSwitcherProps) {
</SelectItem>
</SelectGroup> : undefined}
{props.allowNull && <SelectGroup>
<SelectItem value="null-sentinel">
<div className="flex items-center gap-2">
<TeamIcon team='personal' />
<Typography className="max-w-40 truncate">{props.nullLabel || t('No team')}</Typography>
</div>
</SelectItem>
</SelectGroup>}
{teams?.length ?
<SelectGroup>
<SelectLabel>{t('Other teams')}</SelectLabel>
@ -148,10 +171,12 @@ function Inner(props: SelectedTeamSwitcherProps) {
</div>
</SelectItem>
))}
</SelectGroup> :
</SelectGroup> : null}
{!teams?.length && !props.allowNull ?
<SelectGroup>
<SelectLabel>{t('No teams yet')}</SelectLabel>
</SelectGroup>}
</SelectGroup> : null}
{project.config.clientTeamCreationEnabled && <>
<SelectSeparator/>

View File

@ -1,7 +1,15 @@
import { Avatar, AvatarImage, Typography } from "@stackframe/stack-ui";
import { User2 } from "lucide-react";
import { Team } from "..";
export function TeamIcon(props: { team: Team }) {
export function TeamIcon(props: { team: Team | 'personal' }) {
if (props.team === 'personal') {
return (
<div className="flex items-center justify-center min-w-6 min-h-6 max-w-6 max-h-6 rounded bg-zinc-200">
<User2 className="w-4 h-4" />
</div>
);
}
if (props.team.profileImageUrl) {
return (
<Avatar className="min-w-6 min-h-6 max-w-6 max-h-6 rounded">

File diff suppressed because it is too large Load Diff